diff --git a/.github/workflows/docs stable.yml b/.github/workflows/docs stable.yml index 2790b1d51..98245fa89 100644 --- a/.github/workflows/docs stable.yml +++ b/.github/workflows/docs stable.yml @@ -29,7 +29,7 @@ jobs: # cd fermi && mdbook build -d ../nightly/fermi && cd .. - name: Deploy 🚀 - uses: JamesIves/github-pages-deploy-action@v4.4.1 + uses: JamesIves/github-pages-deploy-action@v4.4.2 with: branch: gh-pages # The branch the action should deploy to. folder: docs/nightly # The folder the action should deploy. diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 07a8b462b..d209c0c1a 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -34,7 +34,7 @@ jobs: # cd fermi && mdbook build -d ../nightly/fermi && cd .. - name: Deploy 🚀 - uses: JamesIves/github-pages-deploy-action@v4.4.1 + uses: JamesIves/github-pages-deploy-action@v4.4.2 with: branch: gh-pages # The branch the action should deploy to. folder: docs/nightly # The folder the action should deploy. diff --git a/.mailmap b/.mailmap new file mode 100644 index 000000000..8562f720b --- /dev/null +++ b/.mailmap @@ -0,0 +1 @@ +Jonathan Kelley diff --git a/Cargo.toml b/Cargo.toml index 377ff37b0..03b5c1296 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -22,7 +22,16 @@ members = [ "packages/rsx-rosetta", "packages/signals", "packages/hot-reload", + "packages/fullstack", + "packages/fullstack/server-macro", + "packages/fullstack/examples/axum-hello-world", + "packages/fullstack/examples/axum-router", + "packages/fullstack/examples/axum-desktop", + "packages/fullstack/examples/salvo-hello-world", + "packages/fullstack/examples/warp-hello-world", "docs/guide", + # Full project examples + "examples/tailwind", "examples/PWA-example", ] @@ -62,7 +71,7 @@ tokio = { version = "1.16.1", features = ["full"] } reqwest = { version = "0.11.9", features = ["json"] } fern = { version = "0.6.0", features = ["colored"] } thiserror = "1.0.30" -env_logger = "0.9.0" +env_logger = "0.10.0" simple_logger = "4.0.0" [profile.release] diff --git a/Makefile.toml b/Makefile.toml index 8ecbe6b2e..6f331b98f 100644 --- a/Makefile.toml +++ b/Makefile.toml @@ -42,10 +42,10 @@ private = true [tasks.test] dependencies = ["build"] command = "cargo" -args = ["test", "--lib", "--bins", "--tests", "--examples", "--workspace", "--exclude", "dioxus-router"] +args = ["test", "--lib", "--bins", "--tests", "--examples", "--workspace", "--exclude", "dioxus-router", "--exclude", "dioxus-desktop"] private = true [tasks.test-with-browser] -env = { CARGO_MAKE_WORKSPACE_INCLUDE_MEMBERS = ["**/packages/router"] } +env = { CARGO_MAKE_WORKSPACE_INCLUDE_MEMBERS = ["**/packages/router", "**/packages/desktop"] } private = true workspace = true diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 000000000..766752ecf --- /dev/null +++ b/docs/README.md @@ -0,0 +1,19 @@ +# Building the Documentation + +Dioxus uses a fork of MdBook with multilanguage support. To build the documentation, you will need to install the forked version of MdBook. + +```sh +cargo install mdbook --git https://github.com/Demonthos/mdBook.git --branch master +``` + +Then, you can build the documentation by running: + +```sh +cd docs +cd guide +mdbook build -d ../nightly/guide +cd .. +cd router +mdbook build -d ../nightly/router +cd ../../ +``` diff --git a/docs/guide/Cargo.toml b/docs/guide/Cargo.toml index 227bdfa35..06b17b0e9 100644 --- a/docs/guide/Cargo.toml +++ b/docs/guide/Cargo.toml @@ -16,12 +16,12 @@ dioxus-native-core-macro = { path = "../../packages/native-core-macro" } dioxus-router = { path = "../../packages/router" } dioxus-liveview = { path = "../../packages/liveview", features = ["axum"] } dioxus-tui = { path = "../../packages/dioxus-tui" } +dioxus-fullstack = { path = "../../packages/fullstack" } +# dioxus = { path = "../../packages/dioxus", features = ["desktop", "web", "ssr", "router", "fermi", "tui"] } fermi = { path = "../../packages/fermi" } shipyard = "0.6.2" - - -# dioxus = { path = "../../packages/dioxus", features = ["desktop", "web", "ssr", "router", "fermi", "tui"] } serde = { version = "1.0.138", features=["derive"] } reqwest = { version = "0.11.11", features = ["json"] } -tokio = { version = "1.19.2" , features=[]} +tokio = { version = "1.19.2", features = ["full"] } axum = { version = "0.6.1", features = ["ws"] } +gloo-storage = "0.2.2" diff --git a/docs/guide/examples/hello_world_ssr.rs b/docs/guide/examples/hello_world_ssr.rs new file mode 100644 index 000000000..0f8db0b46 --- /dev/null +++ b/docs/guide/examples/hello_world_ssr.rs @@ -0,0 +1,63 @@ +#![allow(unused)] +#![allow(non_snake_case)] +// ANCHOR: all + +// ANCHOR: main +#![allow(non_snake_case)] +use axum::{response::Html, routing::get, Router}; +// import the prelude to get access to the `rsx!` macro and the `Scope` and `Element` types +use dioxus::prelude::*; + +#[tokio::main] +async fn main() { + let addr = std::net::SocketAddr::from(([127, 0, 0, 1], 3000)); + println!("listening on http://{}", addr); + + axum::Server::bind(&addr) + .serve( + Router::new() + .route("/", get(app_endpoint)) + .into_make_service(), + ) + .await + .unwrap(); +} + +// ANCHOR_END: main + +// ANCHOR: endpoint +async fn app_endpoint() -> Html { + // render the rsx! macro to HTML + Html(dioxus_ssr::render_lazy(rsx! { + div { "hello world!" } + })) +} +// ANCHOR_END: endpoint + +// ANCHOR: second_endpoint +async fn second_app_endpoint() -> Html { + // create a component that renders a div with the text "hello world" + fn app(cx: Scope) -> Element { + cx.render(rsx!(div { "hello world" })) + } + // create a VirtualDom with the app component + let mut app = VirtualDom::new(app); + // rebuild the VirtualDom before rendering + let _ = app.rebuild(); + + // render the VirtualDom to HTML + Html(dioxus_ssr::render(&app)) +} +// ANCHOR_END: second_endpoint + +// ANCHOR: component +// define a component that renders a div with the text "Hello, world!" +fn App(cx: Scope) -> Element { + cx.render(rsx! { + div { + "Hello, world!" + } + }) +} +// ANCHOR_END: component +// ANCHOR_END: all diff --git a/docs/guide/examples/hooks_anti_patterns.rs b/docs/guide/examples/hooks_anti_patterns.rs new file mode 100644 index 000000000..2119b8601 --- /dev/null +++ b/docs/guide/examples/hooks_anti_patterns.rs @@ -0,0 +1,38 @@ +#![allow(unused)] + +use dioxus::prelude::*; + +fn main() {} + +// ANCHOR: non_clone_state +use std::cell::RefCell; +use std::rc::Rc; +use std::sync::Arc; + +struct UseState<'a, T> { + value: &'a RefCell, + update: Arc, +} + +fn my_use_state(cx: &ScopeState, init: impl FnOnce() -> T) -> UseState { + // The update function will trigger a re-render in the component cx is attached to + let update = cx.schedule_update(); + // Create the initial state + let value = cx.use_hook(|| RefCell::new(init())); + + UseState { value, update } +} + +impl UseState<'_, T> { + fn get(&self) -> T { + self.value.borrow().clone() + } + + fn set(&self, value: T) { + // Update the state + *self.value.borrow_mut() = value; + // Trigger a re-render on the component the state is from + (self.update)(); + } +} +// ANCHOR_END: non_clone_state diff --git a/docs/guide/examples/hooks_composed.rs b/docs/guide/examples/hooks_composed.rs index 801474aa3..782a19d7d 100644 --- a/docs/guide/examples/hooks_composed.rs +++ b/docs/guide/examples/hooks_composed.rs @@ -11,3 +11,57 @@ fn use_settings(cx: &ScopeState) -> &UseSharedState { use_shared_state::(cx).expect("App settings not provided") } // ANCHOR_END: wrap_context + +// ANCHOR: use_storage +use gloo_storage::{LocalStorage, Storage}; +use serde::{de::DeserializeOwned, Serialize}; + +/// A persistent storage hook that can be used to store data across application reloads. +#[allow(clippy::needless_return)] +pub fn use_persistent( + cx: &ScopeState, + // A unique key for the storage entry + key: impl ToString, + // A function that returns the initial value if the storage entry is empty + init: impl FnOnce() -> T, +) -> &UsePersistent { + // Use the use_ref hook to create a mutable state for the storage entry + let state = use_ref(cx, move || { + // This closure will run when the hook is created + let key = key.to_string(); + let value = LocalStorage::get(key.as_str()).ok().unwrap_or_else(init); + StorageEntry { key, value } + }); + + // Wrap the state in a new struct with a custom API + // Note: We use use_hook here so that this hook is easier to use in closures in the rsx. Any values with the same lifetime as the ScopeState can be used in the closure without cloning. + cx.use_hook(|| UsePersistent { + inner: state.clone(), + }) +} + +struct StorageEntry { + key: String, + value: T, +} + +/// Storage that persists across application reloads +pub struct UsePersistent { + inner: UseRef>, +} + +impl UsePersistent { + /// Returns a reference to the value + pub fn get(&self) -> T { + self.inner.read().value.clone() + } + + /// Sets the value + pub fn set(&self, value: T) { + let mut inner = self.inner.write(); + // Write the new value to local storage + LocalStorage::set(inner.key.as_str(), &value); + inner.value = value; + } +} +// ANCHOR_END: use_storage diff --git a/docs/guide/examples/hooks_custom_logic.rs b/docs/guide/examples/hooks_custom_logic.rs new file mode 100644 index 000000000..2323ddb11 --- /dev/null +++ b/docs/guide/examples/hooks_custom_logic.rs @@ -0,0 +1,57 @@ +#![allow(unused)] + +use dioxus::prelude::*; + +fn main() {} + +// ANCHOR: use_state +use std::cell::RefCell; +use std::rc::Rc; +use std::sync::Arc; + +#[derive(Clone)] +struct UseState { + value: Rc>, + update: Arc, +} + +fn my_use_state(cx: &ScopeState, init: impl FnOnce() -> T) -> &UseState { + cx.use_hook(|| { + // The update function will trigger a re-render in the component cx is attached to + let update = cx.schedule_update(); + // Create the initial state + let value = Rc::new(RefCell::new(init())); + + UseState { value, update } + }) +} + +impl UseState { + fn get(&self) -> T { + self.value.borrow().clone() + } + + fn set(&self, value: T) { + // Update the state + *self.value.borrow_mut() = value; + // Trigger a re-render on the component the state is from + (self.update)(); + } +} +// ANCHOR_END: use_state + +// ANCHOR: use_context +pub fn use_context(cx: &ScopeState) -> Option<&T> { + cx.use_hook(|| cx.consume_context::()).as_ref() +} + +pub fn use_context_provider(cx: &ScopeState, f: impl FnOnce() -> T) -> &T { + cx.use_hook(|| { + let val = f(); + // Provide the context state to the scope + cx.provide_context(val.clone()); + val + }) +} + +// ANCHOR_END: use_context diff --git a/docs/guide/examples/hydration.rs b/docs/guide/examples/hydration.rs new file mode 100644 index 000000000..31574edce --- /dev/null +++ b/docs/guide/examples/hydration.rs @@ -0,0 +1,34 @@ +#![allow(non_snake_case, unused)] +use dioxus::prelude::*; + +fn main() { + #[cfg(feature = "web")] + dioxus_web::launch_cfg(app, dioxus_web::Config::new().hydrate(true)); + #[cfg(feature = "ssr")] + { + use dioxus_fullstack::prelude::*; + tokio::runtime::Runtime::new() + .unwrap() + .block_on(async move { + let addr = std::net::SocketAddr::from(([127, 0, 0, 1], 8080)); + axum::Server::bind(&addr) + .serve( + axum::Router::new() + .serve_dioxus_application("", ServeConfigBuilder::new(app, ())) + .into_make_service(), + ) + .await + .unwrap(); + }); + } +} + +fn app(cx: Scope) -> Element { + let mut count = use_state(cx, || 0); + + cx.render(rsx! { + h1 { "High-Five counter: {count}" } + button { onclick: move |_| count += 1, "Up high!" } + button { onclick: move |_| count -= 1, "Down low!" } + }) +} diff --git a/docs/guide/examples/hydration_props.rs b/docs/guide/examples/hydration_props.rs new file mode 100644 index 000000000..4e6bb1752 --- /dev/null +++ b/docs/guide/examples/hydration_props.rs @@ -0,0 +1,74 @@ +#![allow(non_snake_case, unused)] +use dioxus::prelude::*; +use dioxus_fullstack::prelude::*; + +fn main() { + #[cfg(feature = "web")] + dioxus_web::launch_with_props( + app, + // Get the root props from the document + get_root_props_from_document().unwrap_or_default(), + dioxus_web::Config::new().hydrate(true), + ); + #[cfg(feature = "ssr")] + { + use axum::extract::Path; + use axum::extract::State; + use axum::routing::get; + tokio::runtime::Runtime::new() + .unwrap() + .block_on(async move { + let addr = std::net::SocketAddr::from(([127, 0, 0, 1], 8080)); + axum::Server::bind(&addr) + .serve( + axum::Router::new() + // Serve the dist folder with the static javascript and WASM files created by the dixous CLI + .serve_static_assets("./dist") + // Register server functions + .register_server_fns("") + // Connect to the hot reload server in debug mode + .connect_hot_reload() + // Render the application. This will serialize the root props (the intial count) into the HTML + .route( + "/", + get(move | State(ssr_state): State| async move { axum::body::Full::from( + ssr_state.render( + &ServeConfigBuilder::new( + app, + 0, + ) + .build(), + ) + )}), + ) + // Render the application with a different intial count + .route( + "/:initial_count", + get(move |Path(intial_count): Path, State(ssr_state): State| async move { axum::body::Full::from( + ssr_state.render( + &ServeConfigBuilder::new( + app, + intial_count, + ) + .build(), + ) + )}), + ) + .with_state(SSRState::default()) + .into_make_service(), + ) + .await + .unwrap(); + }); + } +} + +fn app(cx: Scope) -> Element { + let mut count = use_state(cx, || *cx.props); + + cx.render(rsx! { + h1 { "High-Five counter: {count}" } + button { onclick: move |_| count += 1, "Up high!" } + button { onclick: move |_| count -= 1, "Down low!" } + }) +} diff --git a/docs/guide/examples/readme_expanded.rs b/docs/guide/examples/readme_expanded.rs new file mode 100644 index 000000000..896d365e5 --- /dev/null +++ b/docs/guide/examples/readme_expanded.rs @@ -0,0 +1,107 @@ +use dioxus::prelude::*; + +fn main() { + dioxus_desktop::launch(app); +} + +fn app(cx: Scope) -> Element { + let mut count = use_state(cx, || 0); + + cx.render( + // rsx expands to LazyNodes::new + ::dioxus::core::LazyNodes::new( + move |__cx: &::dioxus::core::ScopeState| -> ::dioxus::core::VNode { + // The template is every static part of the rsx + static TEMPLATE: ::dioxus::core::Template = ::dioxus::core::Template { + // This is the source location of the rsx that generated this template. This is used to make hot rsx reloading work. Hot rsx reloading just replaces the template with a new one generated from the rsx by the CLI. + name: "examples\\readme.rs:14:15:250", + // The root nodes are the top level nodes of the rsx + roots: &[ + // The h1 node + ::dioxus::core::TemplateNode::Element { + // Find the built in h1 tag in the dioxus_elements crate exported by the dioxus html crate + tag: dioxus_elements::h1::TAG_NAME, + namespace: dioxus_elements::h1::NAME_SPACE, + attrs: &[], + // The children of the h1 node + children: &[ + // The dynamic count text node + // Any nodes that are dynamic have a dynamic placeholder with a unique index + ::dioxus::core::TemplateNode::DynamicText { + // This index is used to find what element in `dynamic_nodes` to use instead of the placeholder + id: 0usize, + }, + ], + }, + // The up high button node + ::dioxus::core::TemplateNode::Element { + tag: dioxus_elements::button::TAG_NAME, + namespace: dioxus_elements::button::NAME_SPACE, + attrs: &[ + // The dynamic onclick listener attribute + // Any attributes that are dynamic have a dynamic placeholder with a unique index. + ::dioxus::core::TemplateAttribute::Dynamic { + // Similar to dynamic nodes, dynamic attributes have a unique index used to find the attribute in `dynamic_attrs` to use instead of the placeholder + id: 0usize, + }, + ], + children: &[::dioxus::core::TemplateNode::Text { text: "Up high!" }], + }, + // The down low button node + ::dioxus::core::TemplateNode::Element { + tag: dioxus_elements::button::TAG_NAME, + namespace: dioxus_elements::button::NAME_SPACE, + attrs: &[ + // The dynamic onclick listener attribute + ::dioxus::core::TemplateAttribute::Dynamic { id: 1usize }, + ], + children: &[::dioxus::core::TemplateNode::Text { text: "Down low!" }], + }, + ], + // Node paths is a list of paths to every dynamic node in the rsx + node_paths: &[ + // The first node path is the path to the dynamic node with an id of 0 (the count text node) + &[ + // Go to the index 0 root node + 0u8, + // + // Go to the first child of the root node + 0u8, + ], + ], + // Attr paths is a list of paths to every dynamic attribute in the rsx + attr_paths: &[ + // The first attr path is the path to the dynamic attribute with an id of 0 (the up high button onclick listener) + &[ + // Go to the index 1 root node + 1u8, + ], + // The second attr path is the path to the dynamic attribute with an id of 1 (the down low button onclick listener) + &[ + // Go to the index 2 root node + 2u8, + ], + ], + }; + // The VNode is a reference to the template with the dynamic parts of the rsx + ::dioxus::core::VNode { + parent: None, + key: None, + // The static template this node will use. The template is stored in a Cell so it can be replaced with a new template when hot rsx reloading is enabled + template: std::cell::Cell::new(TEMPLATE), + root_ids: Default::default(), + dynamic_nodes: __cx.bump().alloc([ + // The dynamic count text node (dynamic node id 0) + __cx.text_node(format_args!("High-Five counter: {0}", count)), + ]), + dynamic_attrs: __cx.bump().alloc([ + // The dynamic up high button onclick listener (dynamic attribute id 0) + dioxus_elements::events::onclick(__cx, move |_| count += 1), + // The dynamic down low button onclick listener (dynamic attribute id 1) + dioxus_elements::events::onclick(__cx, move |_| count -= 1), + ]), + } + }, + ), + ) +} diff --git a/docs/guide/examples/server_basic.rs b/docs/guide/examples/server_basic.rs new file mode 100644 index 000000000..858fcfd50 --- /dev/null +++ b/docs/guide/examples/server_basic.rs @@ -0,0 +1,30 @@ +#![allow(non_snake_case, unused)] +use dioxus::prelude::*; + +#[tokio::main] +async fn main() { + #[cfg(feature = "ssr")] + { + use dioxus_fullstack::prelude::*; + + let addr = std::net::SocketAddr::from(([127, 0, 0, 1], 8080)); + axum::Server::bind(&addr) + .serve( + axum::Router::new() + .serve_dioxus_application("", ServeConfigBuilder::new(app, ())) + .into_make_service(), + ) + .await + .unwrap(); + } +} + +fn app(cx: Scope) -> Element { + let mut count = use_state(cx, || 0); + + cx.render(rsx! { + h1 { "High-Five counter: {count}" } + button { onclick: move |_| count += 1, "Up high!" } + button { onclick: move |_| count -= 1, "Down low!" } + }) +} diff --git a/docs/guide/examples/server_context.rs b/docs/guide/examples/server_context.rs new file mode 100644 index 000000000..446b0a93a --- /dev/null +++ b/docs/guide/examples/server_context.rs @@ -0,0 +1,112 @@ +#![allow(non_snake_case, unused)] +use dioxus::prelude::*; +use dioxus_fullstack::prelude::*; + +fn main() { + #[cfg(feature = "web")] + dioxus_web::launch_with_props( + app, + // Get the root props from the document + get_root_props_from_document().unwrap_or_default(), + dioxus_web::Config::new().hydrate(true), + ); + #[cfg(feature = "ssr")] + { + use axum::extract::Path; + use axum::extract::State; + use axum::routing::get; + + // Register the server function before starting the server + DoubleServer::register().unwrap(); + + tokio::runtime::Runtime::new() + .unwrap() + .block_on(async move { + let addr = std::net::SocketAddr::from(([127, 0, 0, 1], 8080)); + axum::Server::bind(&addr) + .serve( + axum::Router::new() + // Serve the dist folder with the static javascript and WASM files created by the dixous CLI + .serve_static_assets("./dist") + // Register server functions + .register_server_fns("") + // Connect to the hot reload server in debug mode + .connect_hot_reload() + // Render the application. This will serialize the root props (the intial count) into the HTML + .route( + "/", + get(move |State(ssr_state): State| async move { axum::body::Full::from( + ssr_state.render( + &ServeConfigBuilder::new( + app, + 0, + ) + .build(), + ) + )}), + ) + // Render the application with a different intial count + .route( + "/:initial_count", + get(move |Path(intial_count): Path, State(ssr_state): State| async move { axum::body::Full::from( + ssr_state.render( + &ServeConfigBuilder::new( + app, + intial_count, + ) + .build(), + ) + )}), + ) + .with_state(SSRState::default()) + .into_make_service(), + ) + .await + .unwrap(); + }); + } +} + +fn app(cx: Scope) -> Element { + let mut count = use_state(cx, || *cx.props); + + cx.render(rsx! { + h1 { "High-Five counter: {count}" } + button { onclick: move |_| count += 1, "Up high!" } + button { onclick: move |_| count -= 1, "Down low!" } + button { + onclick: move |_| { + to_owned![count]; + let sc = cx.sc(); + async move { + // Call the server function just like a local async function + if let Ok(new_count) = double_server(sc, *count.current()).await { + count.set(new_count); + } + } + }, + "Double" + } + }) +} + +// We use the "getcbor" encoding to make caching easier +#[server(DoubleServer, "", "getcbor")] +async fn double_server(cx: DioxusServerContext, number: usize) -> Result { + // Perform some expensive computation or access a database on the server + tokio::time::sleep(std::time::Duration::from_secs(1)).await; + let result = number * 2; + + println!( + "User Agent {:?}", + cx.request_parts().headers.get("User-Agent") + ); + + // Set the cache control header to 1 hour on the post request + cx.response_headers_mut() + .insert("Cache-Control", "max-age=3600".parse().unwrap()); + + println!("server calculated {result}"); + + Ok(result) +} diff --git a/docs/guide/examples/server_context_state.rs b/docs/guide/examples/server_context_state.rs new file mode 100644 index 000000000..390849ff6 --- /dev/null +++ b/docs/guide/examples/server_context_state.rs @@ -0,0 +1,137 @@ +#![allow(non_snake_case, unused)] +use dioxus::prelude::*; +use dioxus_fullstack::prelude::*; + +#[cfg(feature = "ssr")] +#[derive(Default, Clone)] +struct ServerFunctionState { + call_count: std::sync::Arc, +} + +fn main() { + #[cfg(feature = "web")] + dioxus_web::launch_with_props( + app, + // Get the root props from the document + get_root_props_from_document().unwrap_or_default(), + dioxus_web::Config::new().hydrate(true), + ); + #[cfg(feature = "ssr")] + { + use axum::body::Body; + use axum::extract::Path; + use axum::extract::State; + use axum::http::Request; + use axum::routing::get; + use std::sync::Arc; + + // Register the server function before starting the server + DoubleServer::register().unwrap(); + + tokio::runtime::Runtime::new() + .unwrap() + .block_on(async move { + let addr = std::net::SocketAddr::from(([127, 0, 0, 1], 8080)); + axum::Server::bind(&addr) + .serve( + axum::Router::new() + // Serve the dist folder with the static javascript and WASM files created by the dixous CLI + .serve_static_assets("./dist") + // Register server functions + .register_server_fns_with_handler("", |func| { + move |State(server_fn_state): State, req: Request| async move { + let (parts, body) = req.into_parts(); + let parts: Arc = Arc::new(parts.into()); + let mut server_context = DioxusServerContext::new(parts.clone()); + server_context.insert(server_fn_state); + server_fn_handler(server_context, func.clone(), parts, body).await + } + }) + .with_state(ServerFunctionState::default()) + // Connect to the hot reload server in debug mode + .connect_hot_reload() + // Render the application. This will serialize the root props (the intial count) into the HTML + .route( + "/", + get(move |State(ssr_state): State| async move { axum::body::Full::from( + ssr_state.render( + &ServeConfigBuilder::new( + app, + 0, + ) + .build(), + ) + )}), + ) + // Render the application with a different intial count + .route( + "/:initial_count", + get(move |Path(intial_count): Path, State(ssr_state): State| async move { axum::body::Full::from( + ssr_state.render( + &ServeConfigBuilder::new( + app, + intial_count, + ) + .build(), + ) + )}), + ) + .with_state(SSRState::default()) + .into_make_service(), + ) + .await + .unwrap(); + }); + } +} + +fn app(cx: Scope) -> Element { + let mut count = use_state(cx, || *cx.props); + + cx.render(rsx! { + h1 { "High-Five counter: {count}" } + button { onclick: move |_| count += 1, "Up high!" } + button { onclick: move |_| count -= 1, "Down low!" } + button { + onclick: move |_| { + to_owned![count]; + let sc = cx.sc(); + async move { + // Call the server function just like a local async function + if let Ok(new_count) = double_server(sc, *count.current()).await { + count.set(new_count); + } + } + }, + "Double" + } + }) +} + +// We use the "getcbor" encoding to make caching easier +#[server(DoubleServer, "", "getcbor")] +async fn double_server(cx: DioxusServerContext, number: usize) -> Result { + // Perform some expensive computation or access a database on the server + tokio::time::sleep(std::time::Duration::from_secs(1)).await; + let result = number * 2; + + println!( + "User Agent {:?}", + cx.request_parts().headers.get("User-Agent") + ); + + // Set the cache control header to 1 hour on the post request + cx.response_headers_mut() + .insert("Cache-Control", "max-age=3600".parse().unwrap()); + + // Get the server function state + let server_fn_state = cx.get::().unwrap(); + let call_count = server_fn_state + .call_count + .fetch_add(1, std::sync::atomic::Ordering::SeqCst); + println!("server functions have been called {call_count} times"); + + println!("server calculated {result}"); + + Ok(result) +} diff --git a/docs/guide/examples/server_function.rs b/docs/guide/examples/server_function.rs new file mode 100644 index 000000000..d803ea60d --- /dev/null +++ b/docs/guide/examples/server_function.rs @@ -0,0 +1,99 @@ +#![allow(non_snake_case, unused)] +use dioxus::prelude::*; +use dioxus_fullstack::prelude::*; + +fn main() { + #[cfg(feature = "web")] + dioxus_web::launch_with_props( + app, + // Get the root props from the document + get_root_props_from_document().unwrap_or_default(), + dioxus_web::Config::new().hydrate(true), + ); + #[cfg(feature = "ssr")] + { + use axum::extract::Path; + use axum::extract::State; + use axum::routing::get; + + // Register the server function before starting the server + DoubleServer::register().unwrap(); + + tokio::runtime::Runtime::new() + .unwrap() + .block_on(async move { + let addr = std::net::SocketAddr::from(([127, 0, 0, 1], 8080)); + axum::Server::bind(&addr) + .serve( + axum::Router::new() + // Serve the dist folder with the static javascript and WASM files created by the dixous CLI + .serve_static_assets("./dist") + // Register server functions + .register_server_fns("") + // Connect to the hot reload server in debug mode + .connect_hot_reload() + // Render the application. This will serialize the root props (the intial count) into the HTML + .route( + "/", + get(move |Path(intial_count): Path, State(ssr_state): State| async move { axum::body::Full::from( + ssr_state.render( + &ServeConfigBuilder::new( + app, + intial_count, + ) + .build(), + ) + )}), + ) + // Render the application with a different intial count + .route( + "/:initial_count", + get(move |Path(intial_count): Path, State(ssr_state): State| async move { axum::body::Full::from( + ssr_state.render( + &ServeConfigBuilder::new( + app, + intial_count, + ) + .build(), + ) + )}), + ) + .with_state(SSRState::default()) + .into_make_service(), + ) + .await + .unwrap(); + }); + } +} + +fn app(cx: Scope) -> Element { + let mut count = use_state(cx, || *cx.props); + + cx.render(rsx! { + h1 { "High-Five counter: {count}" } + button { onclick: move |_| count += 1, "Up high!" } + button { onclick: move |_| count -= 1, "Down low!" } + button { + onclick: move |_| { + to_owned![count]; + async move { + // Call the server function just like a local async function + if let Ok(new_count) = double_server(*count.current()).await { + count.set(new_count); + } + } + }, + "Double" + } + }) +} + +#[server(DoubleServer)] +async fn double_server(number: usize) -> Result { + // Perform some expensive computation or access a database on the server + tokio::time::sleep(std::time::Duration::from_secs(1)).await; + let result = number * 2; + println!("server calculated {result}"); + Ok(result) +} diff --git a/docs/guide/src/en/SUMMARY.md b/docs/guide/src/en/SUMMARY.md index f9f31ed52..62ef5adf7 100644 --- a/docs/guide/src/en/SUMMARY.md +++ b/docs/guide/src/en/SUMMARY.md @@ -6,6 +6,7 @@ - [Desktop](getting_started/desktop.md) - [Web](getting_started/web.md) - [Server-Side Rendering](getting_started/ssr.md) + - [Fullstack](getting_started/fullstack.md) - [Liveview](getting_started/liveview.md) - [Terminal UI](getting_started/tui.md) - [Mobile](getting_started/mobile.md) @@ -31,14 +32,24 @@ - [Error Handling](best_practices/error_handling.md) - [Antipatterns](best_practices/antipatterns.md) - [Publishing](publishing/index.md) + - [Desktop](publishing/desktop.md) - [Web](publishing/web.md) --- +- [Fullstack](fullstack/index.md) + - [Getting Started](fullstack/getting_started.md) + - [Communicating with the Server](fullstack/server_functions.md) + +--- + - [Custom Renderer](custom_renderer/index.md) --- -[Roadmap](roadmap.md) -[Contributing](contributing.md) +- [Contributing](contributing/index.md) + - [Project Structure](contributing/project_structure.md) + - [Walkthrough of Internals](contributing/walkthrough_readme.md) + - [Guiding Principles](contributing/guiding_principles.md) + - [Roadmap](contributing/roadmap.md) diff --git a/docs/guide/src/en/contributing/guiding_principles.md b/docs/guide/src/en/contributing/guiding_principles.md new file mode 100644 index 000000000..5c730d622 --- /dev/null +++ b/docs/guide/src/en/contributing/guiding_principles.md @@ -0,0 +1,37 @@ +# Overall Goals + +This document outlines some of the overall goals for Dioxus. These goals are not set in stone, but they represent general guidelines for the project. + +The goal of Dioxus is to make it easy to build **cross-platform applications that scale**. + +## Cross-Platform + +Dioxus is designed to be cross-platform by default. This means that it should be easy to build applications that run on the web, desktop, and mobile. However, Dioxus should also be flexible enough to allow users to opt into platform-specific features when needed. The `use_eval` is one example of this. By default, Dioxus does not assume that the platform supports JavaScript, but it does provide a hook that allows users to opt into JavaScript when needed. + +## Performance + +As Dioxus applications grow, they should remain relatively performant without the need for manual optimizations. There will be cases where manual optimizations are needed, but Dioxus should try to make these cases as rare as possible. + +One of the benefits of the core architecture of Dioxus is that it delivers reasonable performance even when components are rerendered often. It is based on a Virtual Dom which performs diffing which should prevent unnecessary re-renders even when large parts of the component tree are rerun. On top of this, Dioxus groups static parts of the RSX tree together to skip diffing them entirely. + +## Type Safety + +As teams grow, the Type safety of Rust is a huge advantage. Dioxus should leverage this advantage to make it easy to build applications with large teams. + +To take full advantage of Rust's type system, Dioxus should try to avoid exposing public `Any` types and string-ly typed APIs where possible. + +## Developer Experience + +Dioxus should be easy to learn and ergonomic to use. + +- The API of Dioxus attempts to remain close to React's API where possible. This makes it easier for people to learn Dioxus if they already know React + +- We can avoid the tradeoff between simplicity and flexibility by providing multiple layers of API: One for the very common use case, one for low-level control + + - Hooks: the hooks crate has the most common use cases, but `cx.hook` provides a way to access the underlying persistent reference if needed. + - The builder pattern in platform Configs: The builder pattern is used to default to the most common use case, but users can change the defaults if needed. + +- Documentation: + - All public APIs should have rust documentation + - Examples should be provided for all public features. These examples both serve as documentation and testing. They are checked by CI to ensure that they continue to compile + - The most common workflows should be documented in the guide diff --git a/docs/guide/src/en/contributing.md b/docs/guide/src/en/contributing/index.md similarity index 54% rename from docs/guide/src/en/contributing.md rename to docs/guide/src/en/contributing/index.md index 35efa11ac..0e37db2ad 100644 --- a/docs/guide/src/en/contributing.md +++ b/docs/guide/src/en/contributing/index.md @@ -10,7 +10,7 @@ If you'd like to improve the docs, PRs are welcome! Both Rust docs ([source](htt ## Working on the Ecosystem -Part of what makes React great is the rich ecosystem. We'd like the same for Dioxus! So if you have a library in mind that you'd like to write and many people would benefit from, it will be appreciated. You can [browse npm.js](https://www.npmjs.com/search?q=keywords:react-component) for inspiration. +Part of what makes React great is the rich ecosystem. We'd like the same for Dioxus! So if you have a library in mind that you'd like to write and many people would benefit from, it will be appreciated. You can [browse npm.js](https://www.npmjs.com/search?q=keywords:react-component) for inspiration. Once you are done, add your library to the [awesome dioxus](https://github.com/DioxusLabs/awesome-dioxus) list or share it in the `#I-made-a-thing` channel on [Discord](https://discord.gg/XgGxMSkvUM). ## Bugs & Features @@ -18,3 +18,34 @@ If you've fixed [an open issue](https://github.com/DioxusLabs/dioxus/issues), fe All pull requests (including those made by a team member) must be approved by at least one other team member. Larger, more nuanced decisions about design, architecture, breaking changes, trade-offs, etc. are made by team consensus. + +## Tools + +The following tools can be helpful when developing Dioxus. Many of these tools are used in the CI pipeline. Running them locally before submitting a PR instead of waiting for CI can save time. + +- All code is tested with [cargo test](https://doc.rust-lang.org/cargo/commands/cargo-test.html) + +```sh +cargo fmt --all +``` + +- All code is formatted with [rustfmt](https://github.com/rust-lang/rustfmt) + +```sh +cargo check --workspace --examples --tests +``` + +- All code is linted with [Clippy](https://doc.rust-lang.org/clippy/) + +```sh +cargo clippy --workspace --examples --tests -- -D warnings +``` + +- Crates that use unsafe are checked for undefined behavior with [MIRI](https://github.com/rust-lang/miri). MIRI can be helpful to debug what unsafe code is causing issues. Only code that does not interact with system calls can be checked with MIRI. Currently, this is used for the two MIRI tests in `dioxus-core` and `dioxus-native-core`. + +```sh +cargo miri test --package dioxus-core --test miri_stress +cargo miri test --package dioxus-native-core --test miri_native +``` + +- [Rust analyzer](https://rust-analyzer.github.io/) can be very helpful for quick feedback in your IDE. diff --git a/docs/guide/src/en/contributing/project_structure.md b/docs/guide/src/en/contributing/project_structure.md new file mode 100644 index 000000000..0f4fa64c8 --- /dev/null +++ b/docs/guide/src/en/contributing/project_structure.md @@ -0,0 +1,50 @@ +# Project Struture + +There are many packages in the Dioxus organization. This document will help you understand the purpose of each package and how they fit together. + +## Renderers + +- [Desktop](https://github.com/DioxusLabs/dioxus/tree/master/packages/desktop): A Render that Runs Dioxus applications natively, but renders them with the system webview +- [Mobile](https://github.com/DioxusLabs/dioxus/tree/master/packages/mobile): A Render that Runs Dioxus applications natively, but renders them with the system webview. This is currently a copy of the desktop render +- [Web](https://github.com/DioxusLabs/dioxus/tree/master/packages/Web): Renders Dioxus applications in the browser by compiling to WASM and manipulating the DOM +- [Liveview](https://github.com/DioxusLabs/dioxus/tree/master/packages/liveview): A Render that Runs on the server, and renders using a websocket proxy in the browser +- [Rink](https://github.com/DioxusLabs/dioxus/tree/master/packages/rink): A Renderer that renders a HTML-like tree into a terminal +- [TUI](https://github.com/DioxusLabs/dioxus/tree/master/packages/dioxus-tui): A Renderer that uses Rink to render a Dioxus application in a terminal +- [Blitz-Core](https://github.com/DioxusLabs/blitz/tree/master/blitz-core): An experimental native renderer that renders a HTML-like tree using WGPU. +- [Blitz](https://github.com/DioxusLabs/blitz): An experimental native renderer that uses Blitz-Core to render a Dioxus application using WGPU. +- [SSR](https://github.com/DioxusLabs/dioxus/tree/master/packages/ssr): A Render that Runs Dioxus applications on the server, and renders them to HTML + +## State Management/Hooks + +- [Hooks](https://github.com/DioxusLabs/dioxus/tree/master/packages/hooks): A collection of common hooks for Dioxus applications +- [Signals](https://github.com/DioxusLabs/dioxus/tree/master/packages/signals): A experimental state management library for Dioxus applications. This currently contains a `Copy` version of UseRef +- [Dioxus STD](https://github.com/DioxusLabs/dioxus-std): A collection of platform agnostic hooks to interact with system interfaces (The clipboard, camera, etc.). +- [Fermi](https://github.com/DioxusLabs/dioxus/tree/master/packages/fermi): A global state management library for Dioxus applications. + [Router](https://github.com/DioxusLabs/dioxus/tree/master/packages/router): A client-side router for Dioxus applications + +## Core utilities + +- [core](https://github.com/DioxusLabs/dioxus/tree/master/packages/core): The core virtual dom implementation every Dioxus application uses + - You can read more about the archetecture of the core [in this blog post](https://dioxuslabs.com/blog/templates-diffing/) and the [custom renderer section of the guide](../custom_renderer/index.md) +- [RSX](https://github.com/DioxusLabs/dioxus/tree/master/packages/RSX): The core parsing for RSX used for hot reloading, autoformatting, and the macro +- [core-macro](https://github.com/DioxusLabs/dioxus/tree/master/packages/core-macro): The rsx! macro used to write Dioxus applications. (This is a wrapper over the RSX crate) +- [HTML macro](https://github.com/DioxusLabs/dioxus-html-macro): A html-like alternative to the RSX macro + +## Native Renderer Utilities + +- [native-core](https://github.com/DioxusLabs/dioxus/tree/master/packages/native-core): Incrementally computed tree of states (mostly styles) + - You can read more about how native-core can help you build native renderers in the [custom renderer section of the guide](../custom_renderer/index.html#native-core) +- [native-core-macro](https://github.com/DioxusLabs/dioxus/tree/master/packages/native-core-macro): A helper macro for native core +- [Taffy](https://github.com/DioxusLabs/taffy): Layout engine powering Blitz-Core, Rink, and Bevy UI + +## Web renderer tooling + +- [HTML](https://github.com/DioxusLabs/dioxus/tree/master/packages/html): defines html specific elements, events, and attributes +- [Interpreter](https://github.com/DioxusLabs/dioxus/tree/master/packages/interpreter): defines browser bindings used by the web and desktop renderers + +## Developer tooling + +- [hot-reload](https://github.com/DioxusLabs/dioxus/tree/master/packages/hot-reload): Macro that uses the RSX crate to hot reload static parts of any rsx! macro. This macro works with any non-web renderer with an [integration](https://crates.io/crates/dioxus-hot-reload) +- [autofmt](https://github.com/DioxusLabs/dioxus/tree/master/packages/autofmt): Formats RSX code +- [rsx-rosetta](https://github.com/DioxusLabs/dioxus/tree/master/packages/RSX-rosetta): Handles conversion between HTML and RSX +- [CLI](https://github.com/DioxusLabs/cli): A Command Line Interface and VSCode extension to assist with Dioxus usage diff --git a/docs/guide/src/en/roadmap.md b/docs/guide/src/en/contributing/roadmap.md similarity index 53% rename from docs/guide/src/en/roadmap.md rename to docs/guide/src/en/contributing/roadmap.md index 5e7318de9..fba5913db 100644 --- a/docs/guide/src/en/roadmap.md +++ b/docs/guide/src/en/contributing/roadmap.md @@ -17,53 +17,55 @@ Generally, here's the status of each platform: - **LiveView**: LiveView support is very young. You'll be figuring things out as you go. Thankfully, none of it is too hard and any work can be upstreamed into Dioxus. ## Features + --- | Feature | Status | Description | | ------------------------- | ------ | -------------------------------------------------------------------- | -| Conditional Rendering | ✅ | if/then to hide/show component | -| Map, Iterator | ✅ | map/filter/reduce to produce rsx! | -| Keyed Components | ✅ | advanced diffing with keys | -| Web | ✅ | renderer for web browser | -| Desktop (webview) | ✅ | renderer for desktop | -| Shared State (Context) | ✅ | share state through the tree | -| Hooks | ✅ | memory cells in components | -| SSR | ✅ | render directly to string | -| Component Children | ✅ | cx.children() as a list of nodes | -| Headless components | ✅ | components that don't return real elements | -| Fragments | ✅ | multiple elements without a real root | -| Manual Props | ✅ | Manually pass in props with spread syntax | -| Controlled Inputs | ✅ | stateful wrappers around inputs | -| CSS/Inline Styles | ✅ | syntax for inline styles/attribute groups | -| Custom elements | ✅ | Define new element primitives | -| Suspense | ✅ | schedule future render from future/promise | -| Integrated error handling | ✅ | Gracefully handle errors with ? syntax | -| NodeRef | ✅ | gain direct access to nodes | -| Re-hydration | ✅ | Pre-render to HTML to speed up first contentful paint | -| Jank-Free Rendering | ✅ | Large diffs are segmented across frames for silky-smooth transitions | -| Effects | ✅ | Run effects after a component has been committed to render | +| Conditional Rendering | ✅ | if/then to hide/show component | +| Map, Iterator | ✅ | map/filter/reduce to produce rsx! | +| Keyed Components | ✅ | advanced diffing with keys | +| Web | ✅ | renderer for web browser | +| Desktop (webview) | ✅ | renderer for desktop | +| Shared State (Context) | ✅ | share state through the tree | +| Hooks | ✅ | memory cells in components | +| SSR | ✅ | render directly to string | +| Component Children | ✅ | cx.children() as a list of nodes | +| Headless components | ✅ | components that don't return real elements | +| Fragments | ✅ | multiple elements without a real root | +| Manual Props | ✅ | Manually pass in props with spread syntax | +| Controlled Inputs | ✅ | stateful wrappers around inputs | +| CSS/Inline Styles | ✅ | syntax for inline styles/attribute groups | +| Custom elements | ✅ | Define new element primitives | +| Suspense | ✅ | schedule future render from future/promise | +| Integrated error handling | ✅ | Gracefully handle errors with ? syntax | +| NodeRef | ✅ | gain direct access to nodes | +| Re-hydration | ✅ | Pre-render to HTML to speed up first contentful paint | +| Jank-Free Rendering | ✅ | Large diffs are segmented across frames for silky-smooth transitions | +| Effects | ✅ | Run effects after a component has been committed to render | | Portals | 🛠 | Render nodes outside of the traditional tree structure | | Cooperative Scheduling | 🛠 | Prioritize important events over non-important events | | Server Components | 🛠 | Hybrid components for SPA and Server | -| Bundle Splitting | 👀 | Efficiently and asynchronously load the app | -| Lazy Components | 👀 | Dynamically load the new components as the page is loaded | -| 1st class global state | ✅ | redux/recoil/mobx on top of context | -| Runs natively | ✅ | runs as a portable binary w/o a runtime (Node) | -| Subtree Memoization | ✅ | skip diffing static element subtrees | -| High-efficiency templates | ✅ | rsx! calls are translated to templates on the DOM's side | -| Compile-time correct | ✅ | Throw errors on invalid template layouts | -| Heuristic Engine | ✅ | track component memory usage to minimize future allocations | -| Fine-grained reactivity | 👀 | Skip diffing for fine-grain updates | +| Bundle Splitting | 👀 | Efficiently and asynchronously load the app | +| Lazy Components | 👀 | Dynamically load the new components as the page is loaded | +| 1st class global state | ✅ | redux/recoil/mobx on top of context | +| Runs natively | ✅ | runs as a portable binary w/o a runtime (Node) | +| Subtree Memoization | ✅ | skip diffing static element subtrees | +| High-efficiency templates | ✅ | rsx! calls are translated to templates on the DOM's side | +| Compile-time correct | ✅ | Throw errors on invalid template layouts | +| Heuristic Engine | ✅ | track component memory usage to minimize future allocations | +| Fine-grained reactivity | 👀 | Skip diffing for fine-grain updates | - ✅ = implemented and working - 🛠 = actively being worked on - 👀 = not yet implemented or being worked on - ## Roadmap + These Features are planned for the future of Dioxus: ### Core + - [x] Release of Dioxus Core - [x] Upgrade documentation to include more theory and be more comprehensive - [x] Support for HTML-side templates for lightning-fast dom manipulation @@ -72,16 +74,18 @@ These Features are planned for the future of Dioxus: - [ ] Support for Portals ### SSR + - [x] SSR Support + Hydration - [ ] Integrated suspense support for SSR ### Desktop + - [ ] Declarative window management - [ ] Templates for building/bundling -- [ ] Fully native renderer - [ ] Access to Canvas/WebGL context natively ### Mobile + - [ ] Mobile standard library - [ ] GPS - [ ] Camera @@ -92,9 +96,9 @@ These Features are planned for the future of Dioxus: - [ ] Notifications - [ ] Clipboard - [ ] Animations -- [ ] Native Renderer ### Bundling (CLI) + - [x] Translation from HTML into RSX - [x] Dev server - [x] Live reload @@ -106,11 +110,11 @@ These Features are planned for the future of Dioxus: - [ ] Image pipeline ### Essential hooks + - [x] Router - [x] Global state management - [ ] Resize observer - ## Work in Progress ### Build Tool diff --git a/docs/guide/src/en/contributing/walkthrough_readme.md b/docs/guide/src/en/contributing/walkthrough_readme.md new file mode 100644 index 000000000..9165d65a8 --- /dev/null +++ b/docs/guide/src/en/contributing/walkthrough_readme.md @@ -0,0 +1,126 @@ +# Walkthrough of the Hello World Example Internals + +This walkthrough will take you through the internals of the Hello World example program. It will explain how major parts of Dioxus internals interact with each other to take the readme example from a source file to a running application. This guide should serve as a high-level overview of the internals of Dioxus. It is not meant to be a comprehensive guide. + +## The Source File + +We start will a hello world program. This program renders a desktop app with the text "Hello World" in a webview. + +```rust +{{#include ../../../../../examples/readme.rs}} +``` + +[![](https://mermaid.ink/img/pako:eNqNkT1vwyAQhv8KvSlR48HphtQtqjK0S6tuSBGBS0CxwcJHk8rxfy_YVqxKVdR3ug_u4YXrQHmNwOFQ-bMyMhB7fReOJbVxfwyyMSy0l7GSpW1ARda727ksUy5MuSyKgvBC5ULA1h5N8WK_kCkfHWHgrBuiXsBynrvdsY9E3u1iM_eyvFOVVadMnELOap-o1911JLPHZ1b-YqLTc3LjTt7WifTZMJPsPdx1ov3Z_ellfcdL8R8vmTy5eUqsTUpZ-vzZzjAEK6gx1NLqtJwuNwSQwRoF8BRqGU4ChOvTORnJf3w7BZxCxBXERkvCjZXpQTXwg6zaVEVtyYe3cdvD0vsf4bucgw?type=png)](https://mermaid.live/edit#pako:eNqNkT1vwyAQhv8KvSlR48HphtQtqjK0S6tuSBGBS0CxwcJHk8rxfy_YVqxKVdR3ug_u4YXrQHmNwOFQ-bMyMhB7fReOJbVxfwyyMSy0l7GSpW1ARda727ksUy5MuSyKgvBC5ULA1h5N8WK_kCkfHWHgrBuiXsBynrvdsY9E3u1iM_eyvFOVVadMnELOap-o1911JLPHZ1b-YqLTc3LjTt7WifTZMJPsPdx1ov3Z_ellfcdL8R8vmTy5eUqsTUpZ-vzZzjAEK6gx1NLqtJwuNwSQwRoF8BRqGU4ChOvTORnJf3w7BZxCxBXERkvCjZXpQTXwg6zaVEVtyYe3cdvD0vsf4bucgw) + +## The rsx! Macro + +Before the Rust compiler runs the program, it will expand all macros. Here is what the hello world example looks like expanded: + +```rust +{{#include ../../../examples/readme_expanded.rs}} +``` + +The rsx macro separates the static parts of the rsx (the template) and the dynamic parts (the dynamic_nodes and dynamic_attributes). + +The static template only contains the parts of the rsx that cannot change at runtime with holes for the dynamic parts: + +[![](https://mermaid.ink/img/pako:eNqdksFuwjAMhl8l8wkkKtFx65njdtm0E0GVSQKJoEmVOgKEeHecUrXStO0wn5Lf9u8vcm6ggjZQwf4UzspiJPH2Ib3g6NLuELG1oiMkp0TsLs9EDu2iUeSCH8tz2HJmy3lRFPrqsXGq9mxeLzcbCU6LZSUGXWRdwnY7tY7Tdoko-Dq1U64fODgiUfzJMeuOe7_ZGq-ny2jNhGQu9DqT8NUK6w72RcL8dxgdzv4PnHLAKf-Fk80HoBUDrfkqeBkTUd8EC2hMbNBpXtYtJySQNQ0PqPioMR4lSH_nOkwUPq9eQUUxmQWkViOZtUN-UwPVHk8dq0Y7CvH9uf3-E9wfrmuk1A?type=png)](https://mermaid.live/edit#pako:eNqdksFuwjAMhl8l8wkkKtFx65njdtm0E0GVSQKJoEmVOgKEeHecUrXStO0wn5Lf9u8vcm6ggjZQwf4UzspiJPH2Ib3g6NLuELG1oiMkp0TsLs9EDu2iUeSCH8tz2HJmy3lRFPrqsXGq9mxeLzcbCU6LZSUGXWRdwnY7tY7Tdoko-Dq1U64fODgiUfzJMeuOe7_ZGq-ny2jNhGQu9DqT8NUK6w72RcL8dxgdzv4PnHLAKf-Fk80HoBUDrfkqeBkTUd8EC2hMbNBpXtYtJySQNQ0PqPioMR4lSH_nOkwUPq9eQUUxmQWkViOZtUN-UwPVHk8dq0Y7CvH9uf3-E9wfrmuk1A) + +The dynamic_nodes and dynamic_attributes are the parts of the rsx that can change at runtime: + +[![](https://mermaid.ink/img/pako:eNp1UcFOwzAM_RXLVzZpvUbighDiABfgtkxTlnirtSaZUgc0df130hZEEcwny35-79nu0EZHqHDfxA9bmyTw9KIDlGjz7pDMqQZ3DsazhVCQ7dQbwnEiKxwDvN3NqhN4O4C3q_VaIztYKXjkQ7184HcCG3MQSgq6Mes1bjbTPAV3RdqIJN5l-V__2_Fcf5iY68dgG7ZHBT4WD5ftZfIBN7dQ_Tj4w1B9MVTXGZa_GMYdcIGekjfsymW7oaFRavKkUZXUmXTUqENfcCZLfD0Hi0pSpgXmkzNC92zKATyqvWnaUiXHEtPz9KrxY_0nzYOPmA?type=png)](https://mermaid.live/edit#pako:eNp1UcFOwzAM_RXLVzZpvUbighDiABfgtkxTlnirtSaZUgc0df130hZEEcwny35-79nu0EZHqHDfxA9bmyTw9KIDlGjz7pDMqQZ3DsazhVCQ7dQbwnEiKxwDvN3NqhN4O4C3q_VaIztYKXjkQ7184HcCG3MQSgq6Mes1bjbTPAV3RdqIJN5l-V__2_Fcf5iY68dgG7ZHBT4WD5ftZfIBN7dQ_Tj4w1B9MVTXGZa_GMYdcIGekjfsymW7oaFRavKkUZXUmXTUqENfcCZLfD0Hi0pSpgXmkzNC92zKATyqvWnaUiXHEtPz9KrxY_0nzYOPmA) + +## Launching the App + +The app is launched by calling the `launch` function with the root component. Internally, this function will create a new web view using [wry](https://docs.rs/wry/latest/wry/) and create a virtual dom with the root component. This guide will not explain the renderer in-depth, but you can read more about it in the [custom renderer](/guide/custom-renderer) section. + +## The Virtual DOM + +Before we dive into the initial render in the virtual dom, we need to discuss what the virtual dom is. The virtual dom is a representation of the dom that is used to diff the current dom from the new dom. This diff is then used to create a list of mutations that need to be applied to the dom. + +The Virtual Dom roughly looks like this: + +```rust +pub struct VirtualDom { + // All the templates that have been created or set durring hot reloading + pub(crate) templates: FxHashMap>>, + + // A slab of all the scopes that have been created + pub(crate) scopes: ScopeSlab, + + // All scopes that have been marked as dirty + pub(crate) dirty_scopes: BTreeSet, + + // Every element is actually a dual reference - one to the template and the other to the dynamic node in that template + pub(crate) elements: Slab, + + // This receiver is used to receive messages from hooks about what scopes need to be marked as dirty + pub(crate) rx: futures_channel::mpsc::UnboundedReceiver, + + // The changes queued up to be sent to the renderer + pub(crate) mutations: Mutations<'static>, +} +``` + +> What is a [slab](https://docs.rs/slab/latest/slab/)? +> A slab acts like a hashmap with integer keys if you don't care about the value of the keys. It is internally backed by a dense vector which makes it more efficient than a hashmap. When you insert a value into a slab, it returns an integer key that you can use to retrieve the value later. + +> How does Dioxus use slabs? +> Dioxus uses "synchronized slabs" to communicate between the renderer and the VDOM. When an node is created in the Virtual Dom, a ElementId is passed along with the mutation to the renderer to identify the node. These ids are used by the Virtual Dom to reference that nodes in future mutations like setting an attribute on a node or removing a node. +> When the renderer sends an event to the Virtual Dom, it sends the ElementId of the node that the event was triggered on. The Virtual Dom uses this id to find the node in the slab and then run the necessary event handlers. + +The virtual dom is a tree of scopes. A new scope is created for every component when it is first rendered and recycled when the component is unmounted. + +Scopes serve three main purposes: + +1. They store the state of hooks used by the component +2. They store the state for the context API +3. They store the current and previous VNode that was rendered for diffing + +### The Initial Render + +The root scope is created and rebuilt: + +1. The root component is run +2. The root component returns a VNode +3. Mutations for the VNode are created and added to the mutation list (this may involve creating new child components) +4. The VNode is stored in the root scope + +After the root scope is built, the mutations are sent to the renderer to be applied to the dom. + +After the initial render, the root scope looks like this: + +[![](https://mermaid.ink/img/pako:eNqtVE1P4zAQ_SuzPrWikRpWXCLtBRDisItWsOxhCaqM7RKricdyJrQV8N93QtvQNCkfEnOynydv3nxkHoVCbUQipjnOVSYDwc_L1AFbWd3dB-kzuEQkuFLoDUwDFkCZAek9nGDh0RlHK__atA1GkUUHf45f0YbppAqB_aOzIAvz-t7-chN_Y-1bw1WSJKsglIu2w9tktWXxIIuHURT5XCqTYa5NmDguw2R8c5MKq2GcgF46WTB_jafi9rZL0yi5q4jQTSrf9altO4okCn1Ratwyz55Qxuku2ITlTMgs6HCQimsPmb3PvqVi-L5gjXP3QcnxWnL8JZLrwGvR31n0KV-Bx6-r-oVkT_-3G1S-NQLbk9i8rj7udP2cixed2QcDCitHJiQw7ub3EVlNecrPjudG2-6soFO5VbMECmR9T5OnlUY4-AFxfw9aTFst3McU9TK1Otm6NEn_DubBYlX2_dglLXOz48FgwJmJ5lZTlhz6xWgNaFnyDgpymcARHO0W2a9J_l5w2wYXvHuGPcqaQ-rESBQmFNJq3nCPNZoK3l4sUSR81DLMUpG6Z_aTFeHV0imRUKjMSFReSzKnVnKGhUimMi8ZNdoShl-rlfmyOUfCS_cPcePz_B_Wl4pc?type=png)](https://mermaid.live/edit#pako:eNqtVE1P4zAQ_SuzPrWikRpWXCLtBRDisItWsOxhCaqM7RKricdyJrQV8N93QtvQNCkfEnOynydv3nxkHoVCbUQipjnOVSYDwc_L1AFbWd3dB-kzuEQkuFLoDUwDFkCZAek9nGDh0RlHK__atA1GkUUHf45f0YbppAqB_aOzIAvz-t7-chN_Y-1bw1WSJKsglIu2w9tktWXxIIuHURT5XCqTYa5NmDguw2R8c5MKq2GcgF46WTB_jafi9rZL0yi5q4jQTSrf9altO4okCn1Ratwyz55Qxuku2ITlTMgs6HCQimsPmb3PvqVi-L5gjXP3QcnxWnL8JZLrwGvR31n0KV-Bx6-r-oVkT_-3G1S-NQLbk9i8rj7udP2cixed2QcDCitHJiQw7ub3EVlNecrPjudG2-6soFO5VbMECmR9T5OnlUY4-AFxfw9aTFst3McU9TK1Otm6NEn_DubBYlX2_dglLXOz48FgwJmJ5lZTlhz6xWgNaFnyDgpymcARHO0W2a9J_l5w2wYXvHuGPcqaQ-rESBQmFNJq3nCPNZoK3l4sUSR81DLMUpG6Z_aTFeHV0imRUKjMSFReSzKnVnKGhUimMi8ZNdoShl-rlfmyOUfCS_cPcePz_B_Wl4pc) + +### Waiting for Events + +The Virtual Dom will only ever rerender a scope if it is marked as dirty. Each hook is responsible for marking the scope as dirty if the state has changed. Hooks can mark a scope as dirty by sending a message to the Virtual Dom's channel. + +There are generally two ways a scope is marked as dirty: + +1. The renderer triggers an event: This causes an event listener to be called if needed which may mark a component as dirty +2. The renderer calls wait for work: This polls futures which may mark a component as dirty + +Once at least one scope is marked as dirty, the renderer can call `render_with_deadline` to diff the dirty scopes. + +### Diffing Scopes + +If the user clicked the "up high" button, the root scope would be marked as dirty by the use_state hook. Once the desktop renderer calls `render_with_deadline`, the root scope would be diffed. + +To start the diffing process, the component is run. After the root component is run it will look like this: + +[![](https://mermaid.ink/img/pako:eNrFVlFP2zAQ_iuen0BrpCaIl0i8AEJ72KQJtpcRFBnbJVYTn-U4tBXw33dpG5M2CetoBfdkny_ffb67fPIT5SAkjekkhxnPmHXk-3WiCVpZ3T9YZjJyDeDIDQcjycRCQVwmCTOGXEBhQEvtVvG1CWUldwo0-XX-6vVIF5W1GB9cWVbI1_PNL5v8jW3uPFbpmFOc2HK-GfA2WG1ZeJSFx0EQmJxxmUEupE01liEd394mVAkyjolYaFYgfu1P6N1dF8Yzua-cA51WphtTWzsLc872Zan9CnEGUkktuk6fFm_i5NxFRwn9bUimHrIvCT3-N2EBM70j5XBNOTwI5TrxmvQJkr7ELcHx67Jeggz0v92g8q0RaE-iP1193On6NyxecKUeJeFQaSdtTMLu_Xah5ctT_u94Nty2ZwU0zxWfxqQA5PecPq84kq9nfRw7SK0WDiEFZ4O37d34S_-08lFBVfb92KVb5HIrAp0WpjKYKeGyODLz0dohWIkaZNkiJqfkdLvIH6oRaTSoEmm0n06k0a5K0ZdpL61Io0Yt0nfpxc7UQ0_9cJrhyZ8syX-6brS706Mc489Vjja7fbWj3cxDqIdfJJqOaCFtwZTAV8hT7U0ovjBQRmiMS8HsNKGJfsE4Vjm4WWhOY2crOaKVEczJS8WwgAWNJywv0SuFcmB_rJ41y9fNiBqm_wA0MS9_AUuAiy0?type=png)](https://mermaid.live/edit#pako:eNrFVlFP2zAQ_iuen0BrpCaIl0i8AEJ72KQJtpcRFBnbJVYTn-U4tBXw33dpG5M2CetoBfdkny_ffb67fPIT5SAkjekkhxnPmHXk-3WiCVpZ3T9YZjJyDeDIDQcjycRCQVwmCTOGXEBhQEvtVvG1CWUldwo0-XX-6vVIF5W1GB9cWVbI1_PNL5v8jW3uPFbpmFOc2HK-GfA2WG1ZeJSFx0EQmJxxmUEupE01liEd394mVAkyjolYaFYgfu1P6N1dF8Yzua-cA51WphtTWzsLc872Zan9CnEGUkktuk6fFm_i5NxFRwn9bUimHrIvCT3-N2EBM70j5XBNOTwI5TrxmvQJkr7ELcHx67Jeggz0v92g8q0RaE-iP1193On6NyxecKUeJeFQaSdtTMLu_Xah5ctT_u94Nty2ZwU0zxWfxqQA5PecPq84kq9nfRw7SK0WDiEFZ4O37d34S_-08lFBVfb92KVb5HIrAp0WpjKYKeGyODLz0dohWIkaZNkiJqfkdLvIH6oRaTSoEmm0n06k0a5K0ZdpL61Io0Yt0nfpxc7UQ0_9cJrhyZ8syX-6brS706Mc489Vjja7fbWj3cxDqIdfJJqOaCFtwZTAV8hT7U0ovjBQRmiMS8HsNKGJfsE4Vjm4WWhOY2crOaKVEczJS8WwgAWNJywv0SuFcmB_rJ41y9fNiBqm_wA0MS9_AUuAiy0) + +Next, the Virtual Dom will compare the new VNode with the previous VNode and only update the parts of the tree that have changed. + +When a component is re-rendered, the Virtual Dom will compare the new VNode with the previous VNode and only update the parts of the tree that have changed. + +The diffing algorithm goes through the list of dynamic attributes and nodes and compares them to the previous VNode. If the attribute or node has changed, a mutation that describes the change is added to the mutation list. + +Here is what the diffing algorithm looks like for the root scope (red lines indicate that a mutation was generated, and green lines indicate that no mutation was generated) + +[![](https://mermaid.ink/img/pako:eNrFlFFPwjAQx7_KpT7Kko2Elya8qCE-aGLAJ5khpe1Yw9Zbug4k4He3OJjbGPig0T5t17tf_nf777aEo5CEkijBNY-ZsfAwDjW4kxfzhWFZDGNECxOOmYTIYAo2lsCyDG4xzVBLbcv8_RHKSG4V6orSIN0Wxrh8b2RYKr_uTyubd1W92GiWKg7aac6bOU3G803HbVk82xfP_Ok0JEqAT-FeLWJvpFYSOBbaSkMhCMnra5MgtfhWFrPWqHlhL2urT6atbU-oa0PNE8WXFFJ0-nazXakRroddGk9IwYEUnCd5w7Pddr5UTT8ZuVJY5F0fM7ebRLYyXNDgUnprJWxM-9lb7xAQLHe-M2xDYQCD9pD_2hez_kVn-P_rjLq6n3qjYv2iO5qz9DyvPdyv1ETp5eTTJ_7BGvQq8v1TVtl5jXUcRRcrqFh-dI4VtFlBN6t_ynLNkh5JpUmZEm5rbvfhkLiN6H4BQt2jYGYZklC_uzxWWJxsNCfUmkL2SJEJZuWdYs4cKaERS3IXlUJZNI_lGv7cxj2SMf2CeMx5_wBcbK19?type=png)](https://mermaid.live/edit#pako:eNrFlFFPwjAQx7_KpT7Kko2Elya8qCE-aGLAJ5khpe1Yw9Zbug4k4He3OJjbGPig0T5t17tf_nf777aEo5CEkijBNY-ZsfAwDjW4kxfzhWFZDGNECxOOmYTIYAo2lsCyDG4xzVBLbcv8_RHKSG4V6orSIN0Wxrh8b2RYKr_uTyubd1W92GiWKg7aac6bOU3G803HbVk82xfP_Ok0JEqAT-FeLWJvpFYSOBbaSkMhCMnra5MgtfhWFrPWqHlhL2urT6atbU-oa0PNE8WXFFJ0-nazXakRroddGk9IwYEUnCd5w7Pddr5UTT8ZuVJY5F0fM7ebRLYyXNDgUnprJWxM-9lb7xAQLHe-M2xDYQCD9pD_2hez_kVn-P_rjLq6n3qjYv2iO5qz9DyvPdyv1ETp5eTTJ_7BGvQq8v1TVtl5jXUcRRcrqFh-dI4VtFlBN6t_ynLNkh5JpUmZEm5rbvfhkLiN6H4BQt2jYGYZklC_uzxWWJxsNCfUmkL2SJEJZuWdYs4cKaERS3IXlUJZNI_lGv7cxj2SMf2CeMx5_wBcbK19) + +## Conclusion + +This is only a brief overview of how the Virtual Dom works. There are several aspects not yet covered in this guide including how the Virtual Dom handles async-components, keyed diffing, and how it uses [bump allocation](https://github.com/fitzgen/bumpalo) to efficiently allocate VNodes. If need more information about the Virtual Dom, you can read the code of the [core](https://github.com/DioxusLabs/dioxus/tree/master/packages/core) crate or reach out to us on [Discord](https://discord.gg/XgGxMSkvUM). diff --git a/docs/guide/src/en/fullstack/getting_started.md b/docs/guide/src/en/fullstack/getting_started.md new file mode 100644 index 000000000..8e753e2c9 --- /dev/null +++ b/docs/guide/src/en/fullstack/getting_started.md @@ -0,0 +1,102 @@ +> This guide assumes you read the [Web](web.md) guide and installed the [Dioxus-cli](https://github.com/DioxusLabs/cli) + +# Getting Started + +## Setup + +For this guide, we're going to show how to use Dioxus with [Axum](https://docs.rs/axum/latest/axum/), but `dioxus-fullstack` also integrates with the [Warp](https://docs.rs/warp/latest/warp/) and [Salvo](https://docs.rs/salvo/latest/salvo/) web frameworks. + +Make sure you have Rust and Cargo installed, and then create a new project: + +```shell +cargo new --bin demo +cd demo +``` + +Add `dioxus` and `dioxus-fullstack` as dependencies: + +```shell +cargo add dioxus +cargo add dioxus-fullstack --features axum, ssr +``` + +Next, add all the Axum dependencies. This will be different if you're using a different Web Framework + +```shell +cargo add tokio --features full +cargo add axum +``` + +Your dependencies should look roughly like this: + +```toml +[dependencies] +axum = "*" +dioxus = { version = "*" } +dioxus-fullstack = { version = "*", features = ["axum", "ssr"] } +tokio = { version = "*", features = ["full"] } +``` + +Now, set up your Axum app to serve the Dioxus app. + +```rust +{{#include ../../../examples/server_basic.rs}} +``` + +Now, run your app with `cargo run` and open `http://localhost:8080` in your browser. You should see a server-side rendered page with a counter. + +## Hydration + +Right now, the page is static. We can't interact with the buttons. To fix this, we can hydrate the page with `dioxus-web`. + +First, modify your `Cargo.toml` to include two features, one for the server called `ssr`, and one for the client called `web`. + +```toml +[dependencies] +# Common dependancies +dioxus = { version = "*" } +dioxus-fullstack = { version = "*" } + +# Web dependancies +dioxus-web = { version = "*", features=["hydrate"], optional = true } + +# Server dependancies +axum = { version = "0.6.12", optional = true } +tokio = { version = "1.27.0", features = ["full"], optional = true } + +[features] +default = [] +ssr = ["axum", "tokio", "dioxus-fullstack/axum"] +web = ["dioxus-web"] +``` + +Next, we need to modify our `main.rs` to use either hydrate on the client or render on the server depending on the active features. + +```rust +{{#include ../../../examples/hydration.rs}} +``` + +Now, build your client-side bundle with `dioxus build --features web` and run your server with `cargo run --features ssr`. You should see the same page as before, but now you can interact with the buttons! + +## Sycronizing props between the server and client + +Let's make the initial count of the counter dynamic based on the current page. + +### Modifying the server + +To do this, we must remove the serve_dioxus_application and replace it with a custom implementation of its four key functions: + +- Serve static WASM and JS files with serve_static_assets +- Register server functions with register_server_fns (more information on server functions later) +- Connect to the hot reload server with connect_hot_reload +- A custom route that uses SSRState to server-side render the application + +### Modifying the client + +The only thing we need to change on the client is the props. `dioxus-fullstack` will automatically serialize the props it uses to server render the app and send them to the client. In the client section of `main.rs`, we need to add `get_root_props_from_document` to deserialize the props before we hydrate the app. + +```rust +{{#include ../../../examples/hydration_props.rs}} +``` + +Now, build your client-side bundle with `dioxus build --features web` and run your server with `cargo run --features ssr`. Navigate to `http://localhost:8080/1` and you should see the counter start at 1. Navigate to `http://localhost:8080/2` and you should see the counter start at 2. diff --git a/docs/guide/src/en/fullstack/index.md b/docs/guide/src/en/fullstack/index.md new file mode 100644 index 000000000..a9b39c8f1 --- /dev/null +++ b/docs/guide/src/en/fullstack/index.md @@ -0,0 +1,59 @@ +# Fullstack development + +So far you have learned about three different approaches to target the web with Dioxus: + +- [Client-side rendering with dioxus-web](../getting_started/web.md) +- [Server-side rendering with dioxus-liveview](../getting_started/liveview.md) +- [Server-side static HTML generation with dioxus-ssr](../getting_started/ssr.md) + +## Summary of Existing Approaches + +Each approach has its tradeoffs: + +### Client-side rendering + +- With Client side rendering, you send the entire content of your application to the client, and then the client generates all of the HTML of the page dynamically. + +- This means that the page will be blank until the JavaScript bundle has loaded and the application has initialized. This can result in **slower first render times and makes the page less SEO-friendly**. + +> SEO stands for Search Engine Optimization. It refers to the practice of making your website more likely to appear in search engine results. Search engines like Google and Bing use web crawlers to index the content of websites. Most of these crawlers are not able to run JavaScript, so they will not be able to index the content of your page if it is rendered client-side. + +- Client-side rendered applications need to use **weakly typed requests to communicate with the server** + +> Client-side rendering is a good starting point for most applications. It is well supported and makes it easy to communicate with the client/browser APIs + +### Liveview + +- Liveview rendering communicates with the server over a WebSocket connection. It essentially moves all of the work that Client-side rendering does to the server. + +- This makes it **easy to communicate with the server, but more difficult to communicate with the client/browser APIS**. + +- Each interaction also requires a message to be sent to the server and back which can cause **issues with latency**. + +- Because Liveview uses a websocket to render, the page will be blank until the WebSocket connection has been established and the first renderer has been sent form the websocket. Just like with client side rendering, this can make the page **less SEO-friendly**. + +- Because the page is rendered on the server and the page is sent to the client piece by piece, you never need to send the entire application to the client. The initial load time can be faster than client-side rendering with large applications because Liveview only needs to send a constant small websocket script regardless of the size of the application. + +> Liveview is a good fit for applications that already need to communicate with the server frequently (like real time collaborative apps), but don't need to communicate with as many client/browser APIs + +### Server-side rendering + +- Server-side rendering generates all of the HTML of the page on the server before the page is sent to the client. This means that the page will be fully rendered when it is sent to the client. This results in a faster first render time and makes the page more SEO-friendly. However, it **only works for static pages**. + +> Server-side rendering is not a good fit for purely static sites like a blog + +## A New Approach + +Each of these approaches has its tradeoffs. What if we could combine the best parts of each approach? + +- **Fast initial render** time like SSR +- **Works well with SEO** like SSR +- **Type safe easy communication with the server** like Liveview +- **Access to the client/browser APIs** like Client-side rendering +- **Fast interactivity** like Client-side rendering + +We can achieve this by rendering the initial page on the server (SSR) and then taking over rendering on the client (Client-side rendering). Taking over rendering on the client is called **hydration**. + +Finally, we can use [server functions](server_functions.md) to communicate with the server in a type-safe way. + +This approach uses both the dioxus-web and dioxus-ssr crates. To integrate those two packages and `axum`, `warp`, or `salvo`, Dioxus provides the `dioxus-fullstack` crate. diff --git a/docs/guide/src/en/fullstack/server_functions.md b/docs/guide/src/en/fullstack/server_functions.md new file mode 100644 index 000000000..b9d40ed2c --- /dev/null +++ b/docs/guide/src/en/fullstack/server_functions.md @@ -0,0 +1,31 @@ +# Communicating with the server + +`dixous-server` provides server functions that allow you to call an automatically generated API on the server from the client as if it were a local function. + +To make a server function, simply add the `#[server(YourUniqueType)]` attribute to a function. The function must: + +- Be an async function +- Have arguments and a return type that both implement serialize and deserialize (with [serde](https://serde.rs/)). +- Return a `Result` with an error type of ServerFnError + +You must call `register` on the type you passed into the server macro in your main function before starting your server to tell Dioxus about the server function. + +Let's continue building on the app we made in the [getting started](./getting_started.md) guide. We will add a server function to our app that allows us to double the count on the server. + +First, add serde as a dependency: + +```shell +cargo add serde +``` + +Next, add the server function to your `main.rs`: + +```rust +{{#include ../../../examples/server_function.rs}} +``` + +Now, build your client-side bundle with `dioxus build --features web` and run your server with `cargo run --features ssr`. You should see a new button that multiplies the count by 2. + +## Conclusion + +That's it! You've created a full-stack Dioxus app. You can find more examples of full-stack apps and information about how to integrate with other frameworks and desktop renderers in the [dioxus-fullstack examples directory](https://github.com/DioxusLabs/dioxus/tree/master/packages/server/examples). diff --git a/docs/guide/src/en/getting_started/fullstack.md b/docs/guide/src/en/getting_started/fullstack.md new file mode 100644 index 000000000..fa3ba67c2 --- /dev/null +++ b/docs/guide/src/en/getting_started/fullstack.md @@ -0,0 +1 @@ +# Fullstack diff --git a/docs/guide/src/en/getting_started/hot_reload.md b/docs/guide/src/en/getting_started/hot_reload.md index 599ce57da..2ebf5e4aa 100644 --- a/docs/guide/src/en/getting_started/hot_reload.md +++ b/docs/guide/src/en/getting_started/hot_reload.md @@ -5,28 +5,35 @@ 3. Currently the cli only implements hot reloading for the web renderer. For TUI, desktop, and LiveView you can use the hot reload macro instead. # Web + For the web renderer, you can use the dioxus cli to serve your application with hot reloading enabled. ## Setup + Install [dioxus-cli](https://github.com/DioxusLabs/cli). Hot reloading is automatically enabled when using the web renderer on debug builds. ## Usage + 1. Run: -```bash + +```bash dioxus serve --hot-reload ``` + 2. Change some code within a rsx or render macro 3. Open your localhost in a browser 4. Save and watch the style change without recompiling -# Desktop/Liveview/TUI +# Desktop/Liveview/TUI/Server + For desktop, LiveView, and tui, you can place the hot reload macro at the top of your main function to enable hot reloading. Hot reloading is automatically enabled on debug builds. For more information about hot reloading on native platforms and configuration options see the [dioxus-hot-reload](https://crates.io/crates/dioxus-hot-reload) crate. ## Setup + Add the following to your main function: ```rust @@ -37,13 +44,17 @@ fn main() { ``` ## Usage + 1. Run: + ```bash cargo run ``` + 2. Change some code within a rsx or render macro 3. Save and watch the style change without recompiling # Limitations + 1. The interpreter can only use expressions that existed on the last full recompile. If you introduce a new variable or expression to the rsx call, it will require a full recompile to capture the expression. 2. Components, Iterators, and some attributes can contain arbitrary rust code and will trigger a full recompile when changed. diff --git a/docs/guide/src/en/getting_started/ssr.md b/docs/guide/src/en/getting_started/ssr.md index 9654003cf..7a1954191 100644 --- a/docs/guide/src/en/getting_started/ssr.md +++ b/docs/guide/src/en/getting_started/ssr.md @@ -1,17 +1,6 @@ # Server-Side Rendering -The Dioxus VirtualDom can be rendered server-side. - -[Example: Dioxus DocSite](https://github.com/dioxusLabs/docsite) - -## Multithreaded Support - -The Dioxus VirtualDom, sadly, is not currently `Send`. Internally, we use quite a bit of interior mutability which is not thread-safe. This means you can't easily use Dioxus with most web frameworks like Tide, Rocket, Axum, etc. - -To solve this, you'll want to spawn a VirtualDom on its own thread and communicate with it via channels. - -When working with web frameworks that require `Send`, it is possible to render a VirtualDom immediately to a String – but you cannot hold the VirtualDom across an await point. For retained-state SSR (essentially LiveView), you'll need to create a pool of VirtualDoms. - +For lower-level control over the rendering process, you can use the `dioxus-ssr` crate directly. This can be useful when integrating with a web framework that `dioxus-server` does not support, or pre-rendering pages. ## Setup @@ -21,7 +10,7 @@ Make sure you have Rust and Cargo installed, and then create a new project: ```shell cargo new --bin demo -cd app +cd demo ``` Add Dioxus and the ssr renderer as dependencies: @@ -51,54 +40,32 @@ tokio = { version = "1.15.0", features = ["full"] } Now, set up your Axum app to respond on an endpoint. ```rust -use axum::{response::Html, routing::get, Router}; -use dioxus::prelude::*; - -#[tokio::main] -async fn main() { - let addr = std::net::SocketAddr::from(([127, 0, 0, 1], 3000)); - println!("listening on http://{}", addr); - - axum::Server::bind(&addr) - .serve( - Router::new() - .route("/", get(app_endpoint)) - .into_make_service(), - ) - .await - .unwrap(); -} +{{#include ../../../examples/hello_world_ssr.rs:main}} ``` And then add our endpoint. We can either render `rsx!` directly: ```rust -async fn app_endpoint() -> Html { - // render the rsx! macro to HTML - Html(dioxus_ssr::render_lazy(rsx! { - div { "hello world!" } - })) -} +{{#include ../../../examples/hello_world_ssr.rs:endpoint}} ``` Or we can render VirtualDoms. ```rust -async fn app_endpoint() -> Html { - // create a component that renders a div with the text "hello world" - fn app(cx: Scope) -> Element { - cx.render(rsx!(div { "hello world" })) - } - // create a VirtualDom with the app component - let mut app = VirtualDom::new(app); - // rebuild the VirtualDom before rendering - let _ = app.rebuild(); +{{#include ../../../examples/hello_world_ssr.rs:second_endpoint}} +``` - // render the VirtualDom to HTML - Html(dioxus_ssr::render_vdom(&app)) -} +And then add our app component: + +```rust +{{#include ../../../examples/hello_world_ssr.rs:component}} ``` And that's it! -> You might notice that you cannot hold the VirtualDom across an await point. Dioxus is currently not ThreadSafe, so it _must_ remain on the thread it started. We are working on loosening this requirement. \ No newline at end of file + +## Multithreaded Support + +The Dioxus VirtualDom, sadly, is not currently `Send`. Internally, we use quite a bit of interior mutability which is not thread-safe. +When working with web frameworks that require `Send`, it is possible to render a VirtualDom immediately to a String – but you cannot hold the VirtualDom across an await point. For retained-state SSR (essentially LiveView), you'll need to spawn a VirtualDom on its own thread and communicate with it via channels or create a pool of VirtualDoms. +You might notice that you cannot hold the VirtualDom across an await point. Because Dioxus is currently not ThreadSafe, it _must_ remain on the thread it started. We are working on loosening this requirement. diff --git a/docs/guide/src/en/getting_started/web.md b/docs/guide/src/en/getting_started/web.md index 76e3b9541..e8a1f3da2 100644 --- a/docs/guide/src/en/getting_started/web.md +++ b/docs/guide/src/en/getting_started/web.md @@ -5,6 +5,7 @@ Build single-page applications that run in the browser with Dioxus. To run on th A build of Dioxus for the web will be roughly equivalent to the size of a React build (70kb vs 65kb) but it will load significantly faster because [WebAssembly can be compiled as it is streamed](https://hacks.mozilla.org/2018/01/making-webassembly-even-faster-firefoxs-new-streaming-and-tiering-compiler/). Examples: + - [TodoMVC](https://github.com/DioxusLabs/example-projects/tree/master/todomvc) - [ECommerce](https://github.com/DioxusLabs/example-projects/tree/master/ecommerce-site) @@ -17,7 +18,7 @@ Examples: The Web is the best-supported target platform for Dioxus. - Because your app will be compiled to WASM you have access to browser APIs through [wasm-bingen](https://rustwasm.github.io/docs/wasm-bindgen/introduction.html). -- Dioxus provides hydration to resume apps that are rendered on the server. See the [hydration example](https://github.com/DioxusLabs/dioxus/blob/master/packages/web/examples/hydrate.rs) for more details. +- Dioxus provides hydration to resume apps that are rendered on the server. See the [fullstack](fullstack.md) getting started guide for more information. ## Tooling @@ -28,6 +29,7 @@ cargo install dioxus-cli ``` Make sure the `wasm32-unknown-unknown` target for rust is installed: + ```shell rustup target add wasm32-unknown-unknown ``` @@ -49,11 +51,11 @@ cargo add dioxus-web ``` Edit your `main.rs`: + ```rust {{#include ../../../examples/hello_world_web.rs}} ``` - And to serve our app: ```bash diff --git a/docs/guide/src/en/interactivity/custom_hooks.md b/docs/guide/src/en/interactivity/custom_hooks.md index 07472bb95..f3acec269 100644 --- a/docs/guide/src/en/interactivity/custom_hooks.md +++ b/docs/guide/src/en/interactivity/custom_hooks.md @@ -2,6 +2,8 @@ Hooks are a great way to encapsulate business logic. If none of the existing hooks work for your problem, you can write your own. +When writing your hook, you can make a function that accepts `cx: &ScopeState` as a parameter to accept a scope with any Props. + ## Composing Hooks To avoid repetition, you can encapsulate business logic based on existing hooks to create a new hook. @@ -12,6 +14,12 @@ For example, if many components need to access an `AppSettings` struct, you can {{#include ../../../examples/hooks_composed.rs:wrap_context}} ``` +Or if you want to wrap a hook that persists reloads with the storage API, you can build on top of the use_ref hook to work with mutable state: + +```rust +{{#include ../../../examples/hooks_composed.rs:use_storage}} +``` + ## Custom Hook Logic You can use [`cx.use_hook`](https://docs.rs/dioxus/latest/dioxus/prelude/struct.ScopeState.html#method.use_hook) to build your own hooks. In fact, this is what all the standard hooks are built on! @@ -23,4 +31,61 @@ You can use [`cx.use_hook`](https://docs.rs/dioxus/latest/dioxus/prelude/struct. Inside the initialization closure, you will typically make calls to other `cx` methods. For example: - The `use_state` hook tracks state in the hook value, and uses [`cx.schedule_update`](https://docs.rs/dioxus/latest/dioxus/prelude/struct.ScopeState.html#method.schedule_update) to make Dioxus re-render the component whenever it changes. + +Here is a simplified implementation of the `use_state` hook: + +```rust +{{#include ../../../examples/hooks_custom_logic.rs:use_state}} +``` + - The `use_context` hook calls [`cx.consume_context`](https://docs.rs/dioxus/latest/dioxus/prelude/struct.ScopeState.html#method.consume_context) (which would be expensive to call on every render) to get some context from the scope + +Here is an implementation of the `use_context` and `use_context_provider` hooks: + +```rust +{{#include ../../../examples/hooks_custom_logic.rs:use_context}} +``` + +## Hook Anti-Patterns + +When writing a custom hook, you should avoid the following anti-patterns: + +- !Clone Hooks: To allow hooks to be used within async blocks, the hooks must be Clone. To make a hook clone, you can wrap data in Rc or Arc and avoid lifetimes in hooks. + +This version of use_state may seem more efficient, but it is not cloneable: + +```rust +{{#include ../../../examples/hooks_anti_patterns.rs:non_clone_state}} +``` + +If we try to use this hook in an async block, we will get a compile error: + +```rust +fn FutureComponent(cx: &ScopeState) -> Element { + let my_state = my_use_state(cx, || 0); + cx.spawn({ + to_owned![my_state]; + async move { + my_state.set(1); + } + }); + + todo!() +} +``` + +But with the original version, we can use it in an async block: + +```rust +fn FutureComponent(cx: &ScopeState) -> Element { + let my_state = use_state(cx, || 0); + cx.spawn({ + to_owned![my_state]; + async move { + my_state.set(1); + } + }); + + todo!() +} +``` diff --git a/docs/guide/src/en/interactivity/dynamic_rendering.md b/docs/guide/src/en/interactivity/dynamic_rendering.md index 290871e9c..5c7ba1314 100644 --- a/docs/guide/src/en/interactivity/dynamic_rendering.md +++ b/docs/guide/src/en/interactivity/dynamic_rendering.md @@ -64,7 +64,7 @@ Example: suppose you have a list of comments you want to render. Then, you can r ### Inline for loops -Because of how common it is to render a list of items, Dioxus provides a shorthand for this. Instead of using `.iter, `.map`, and `rsx`, you can use a `for` loop with a body of rsx code: +Because of how common it is to render a list of items, Dioxus provides a shorthand for this. Instead of using `.iter`, `.map`, and `rsx`, you can use a `for` loop with a body of rsx code: ```rust {{#include ../../../examples/rendering_lists.rs:render_list_for_loop}} diff --git a/docs/guide/src/en/interactivity/hooks.md b/docs/guide/src/en/interactivity/hooks.md index 3539e5884..083f04b49 100644 --- a/docs/guide/src/en/interactivity/hooks.md +++ b/docs/guide/src/en/interactivity/hooks.md @@ -44,7 +44,7 @@ This is only possible because the two hooks are always called in the same order, 1. Hooks may be only used in components or other hooks (we'll get to that later) 2. On every call to the component function - 1. The same hooks must be called + 1. The same hooks must be called (except in the case of early returns, as explained later in the [Error Handling chapter](../best_practices/error_handling.md)) 2. In the same order 3. Hooks name's should start with `use_` so you don't accidentally confuse them with regular functions diff --git a/docs/guide/src/en/interactivity/router.md b/docs/guide/src/en/interactivity/router.md index 90a90e8a9..4133e5e53 100644 --- a/docs/guide/src/en/interactivity/router.md +++ b/docs/guide/src/en/interactivity/router.md @@ -84,4 +84,4 @@ rsx!{ ## More reading -This page is just a very brief overview of the router. For more information, check out [the router book](https://dioxuslabs.com/router/guide/) or some of [the router examples](https://github.com/DioxusLabs/dioxus/blob/master/examples/router.rs). +This page is just a very brief overview of the router. For more information, check out [the router book](https://dioxuslabs.com/docs/0.3/router/) or some of [the router examples](https://github.com/DioxusLabs/dioxus/blob/master/examples/router.rs). diff --git a/docs/guide/src/pt-br/interactivity/router.md b/docs/guide/src/pt-br/interactivity/router.md index d3ac7abb9..382befddb 100644 --- a/docs/guide/src/pt-br/interactivity/router.md +++ b/docs/guide/src/pt-br/interactivity/router.md @@ -80,4 +80,4 @@ rsx!{ Esta página é apenas uma breve visão geral do roteador para mostrar que existe uma solução poderosa já construída para um problema muito comum. Para obter mais informações sobre o roteador, confira seu livro ou confira alguns dos exemplos. -O roteador tem sua própria documentação! [Disponível aqui](https://dioxuslabs.com/router/guide/). +O roteador tem sua própria documentação! [Disponível aqui](https://dioxuslabs.com/docs/0.3/router/). diff --git a/examples/control_focus.rs b/examples/control_focus.rs new file mode 100644 index 000000000..4f4c2ddcb --- /dev/null +++ b/examples/control_focus.rs @@ -0,0 +1,44 @@ +use std::rc::Rc; + +use dioxus::prelude::*; + +fn main() { + dioxus_desktop::launch(app); +} + +fn app(cx: Scope) -> Element { + let elements: &UseRef>> = use_ref(cx, Vec::new); + let running = use_state(cx, || true); + + use_future!(cx, |(elements, running)| async move { + let mut focused = 0; + if *running.current() { + loop { + tokio::time::sleep(std::time::Duration::from_millis(10)).await; + if let Some(element) = elements.read().get(focused) { + element.set_focus(true); + } else { + focused = 0; + } + focused += 1; + } + } + }); + + cx.render(rsx!( + div { + h1 { "Input Roulette" } + for i in 0..100 { + input { + value: "{i}", + onmounted: move |cx| { + elements.write().push(cx.inner().clone()); + }, + oninput: move |_| { + running.set(false); + } + } + } + } + )) +} diff --git a/examples/read_size.rs b/examples/read_size.rs new file mode 100644 index 000000000..806f71f80 --- /dev/null +++ b/examples/read_size.rs @@ -0,0 +1,60 @@ +#![allow(clippy::await_holding_refcell_ref)] +use std::rc::Rc; + +use dioxus::{html::geometry::euclid::Rect, prelude::*}; + +fn main() { + dioxus_desktop::launch_cfg( + app, + dioxus_desktop::Config::default().with_custom_head( + r#" + +"# + .to_owned(), + ), + ); +} + +fn app(cx: Scope) -> Element { + let div_element: &UseRef>> = use_ref(cx, || None); + + let dimentions = use_ref(cx, Rect::zero); + + cx.render(rsx!( + div { + width: "50%", + height: "50%", + background_color: "red", + onmounted: move |cx| { + div_element.set(Some(cx.inner().clone())); + }, + "This element is {dimentions.read():?}" + } + + button { + onclick: move |_| { + to_owned![div_element, dimentions]; + async move { + let read = div_element.read(); + let client_rect = read.as_ref().map(|el| el.get_client_rect()); + if let Some(client_rect) = client_rect { + if let Ok(rect) = client_rect.await { + dimentions.set(rect); + } + } + } + }, + "Read dimentions" + } + )) +} diff --git a/examples/scroll_to_top.rs b/examples/scroll_to_top.rs new file mode 100644 index 000000000..4eb38a451 --- /dev/null +++ b/examples/scroll_to_top.rs @@ -0,0 +1,33 @@ +use dioxus::prelude::*; + +fn main() { + dioxus_desktop::launch(app); +} + +fn app(cx: Scope) -> Element { + let header_element = use_ref(cx, || None); + + cx.render(rsx!( + div { + h1 { + onmounted: move |cx| { + header_element.set(Some(cx.inner().clone())); + }, + "Scroll to top example" + } + + for i in 0..100 { + div { "Item {i}" } + } + + button { + onclick: move |_| { + if let Some(header) = header_element.read().as_ref() { + header.scroll_to(ScrollBehavior::Smooth); + } + }, + "Scroll to top" + } + } + )) +} diff --git a/examples/tailwind/Cargo.toml b/examples/tailwind/Cargo.toml new file mode 100644 index 000000000..5c2fbe666 --- /dev/null +++ b/examples/tailwind/Cargo.toml @@ -0,0 +1,21 @@ +[package] +name = "dioxus-tailwind" +version = "0.0.0" +authors = [] +edition = "2021" +description = "A tailwindcss example using Dioxus" +license = "MIT OR Apache-2.0" +repository = "https://github.com/DioxusLabs/dioxus/" +homepage = "https://dioxuslabs.com" +documentation = "https://dioxuslabs.com" +rust-version = "1.60.0" +publish = false + +[dependencies] +dioxus = { path = "../../packages/dioxus" } + +[target.'cfg(not(target_arch = "wasm32"))'.dependencies] +dioxus-desktop = { path = "../../packages/desktop" } + +[target.'cfg(target_arch = "wasm32")'.dependencies] +dioxus-web = { path = "../../packages/web" } \ No newline at end of file diff --git a/examples/tailwind/Dioxus.toml b/examples/tailwind/Dioxus.toml new file mode 100644 index 000000000..67a63e686 --- /dev/null +++ b/examples/tailwind/Dioxus.toml @@ -0,0 +1,46 @@ +[application] + +# App (Project) Name +name = "Tailwind CSS + Dioxus" + +# Dioxus App Default Platform +# desktop, web, mobile, ssr +default_platform = "web" + +# `build` & `serve` dist path +out_dir = "dist" + +# resource (public) file folder +asset_dir = "public" + +[web.app] + +# HTML title tag content +title = "dioxus | ⛺" + +[web.watcher] + +# when watcher trigger, regenerate the `index.html` +reload_html = true + +# which files or dirs will be watcher monitoring +watch_path = ["src", "public"] + +# include `assets` in web platform +[web.resource] + +# CSS style file +style = ["tailwind.css"] + +# Javascript code file +script = [] + +[web.resource.dev] + +# serve: [dev-server] only + +# CSS style file +style = [] + +# Javascript code file +script = [] diff --git a/examples/tailwind/README.md b/examples/tailwind/README.md new file mode 100644 index 000000000..0498ecbd3 --- /dev/null +++ b/examples/tailwind/README.md @@ -0,0 +1,136 @@ +Example: Basic Tailwind usage + +This example shows how an app might be styled with TailwindCSS. + +# Setup + +1. Install the Dioxus CLI: + +```bash +cargo install --git https://github.com/DioxusLabs/cli +``` + +2. Install npm: https://docs.npmjs.com/downloading-and-installing-node-js-and-npm +3. Install the tailwind css cli: https://tailwindcss.com/docs/installation +4. Initialize the tailwind css project: + +```bash +npx tailwindcss init +``` + +This should create a `tailwind.config.js` file in the root of the project. + +5. Edit the `tailwind.config.js` file to include rust files: + +```json +module.exports = { + mode: "all", + content: [ + // include all rust, html and css files in the src directory + "./src/**/*.{rs,html,css}", + // include all html files in the output (dist) directory + "./dist/**/*.html", + ], + theme: { + extend: {}, + }, + plugins: [], +} +``` + +6. Create a `input.css` file with the following content: + +```css +@tailwind base; +@tailwind components; +@tailwind utilities; +``` + +7. Create a `Dioxus.toml` file with the following content that links to the `tailwind.css` file: + +```toml +[application] + +# App (Project) Name +name = "Tailwind CSS + Dioxus" + +# Dioxus App Default Platform +# desktop, web, mobile, ssr +default_platform = "web" + +# `build` & `serve` dist path +out_dir = "dist" + +# resource (public) file folder +asset_dir = "public" + +[web.app] + +# HTML title tag content +title = "dioxus | ⛺" + +[web.watcher] + +# when watcher trigger, regenerate the `index.html` +reload_html = true + +# which files or dirs will be watcher monitoring +watch_path = ["src", "public"] + +# include `assets` in web platform +[web.resource] + +# CSS style file +style = ["tailwind.css"] + +# Javascript code file +script = [] + +[web.resource.dev] + +# serve: [dev-server] only + +# CSS style file +style = [] + +# Javascript code file +script = [] +``` + +## Bonus Steps + +8. Install the tailwind css vs code extension +9. Go to the settings for the extension and find the experimental regex support section. Edit the setting.json file to look like this: + +```json +"tailwindCSS.experimental.classRegex": ["class: \"(.*)\""], +"tailwindCSS.includeLanguages": { + "rust": "html" +}, +``` + +# Development + +1. Run the following command in the root of the project to start the tailwind css compiler: + +```bash +npx tailwindcss -i ./input.css -o ./public/tailwind.css --watch +``` + +## Web + +- Run the following command in the root of the project to start the dioxus dev server: + +```bash +dioxus serve --hot-reload +``` + +- Open the browser to http://localhost:8080 + +## Desktop + +- Launch the dioxus desktop app + +```bash +cargo run +``` diff --git a/examples/tailwind/input.css b/examples/tailwind/input.css new file mode 100644 index 000000000..bd6213e1d --- /dev/null +++ b/examples/tailwind/input.css @@ -0,0 +1,3 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; \ No newline at end of file diff --git a/examples/tailwind/public/tailwind.css b/examples/tailwind/public/tailwind.css new file mode 100644 index 000000000..65b95316e --- /dev/null +++ b/examples/tailwind/public/tailwind.css @@ -0,0 +1,833 @@ +/* +! tailwindcss v3.2.7 | MIT License | https://tailwindcss.com +*/ + +/* +1. Prevent padding and border from affecting element width. (https://github.com/mozdevs/cssremedy/issues/4) +2. Allow adding a border to an element by just adding a border-width. (https://github.com/tailwindcss/tailwindcss/pull/116) +*/ + +*, +::before, +::after { + box-sizing: border-box; + /* 1 */ + border-width: 0; + /* 2 */ + border-style: solid; + /* 2 */ + border-color: #e5e7eb; + /* 2 */ +} + +::before, +::after { + --tw-content: ''; +} + +/* +1. Use a consistent sensible line-height in all browsers. +2. Prevent adjustments of font size after orientation changes in iOS. +3. Use a more readable tab size. +4. Use the user's configured `sans` font-family by default. +5. Use the user's configured `sans` font-feature-settings by default. +*/ + +html { + line-height: 1.5; + /* 1 */ + -webkit-text-size-adjust: 100%; + /* 2 */ + -moz-tab-size: 4; + /* 3 */ + -o-tab-size: 4; + tab-size: 4; + /* 3 */ + font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"; + /* 4 */ + font-feature-settings: normal; + /* 5 */ +} + +/* +1. Remove the margin in all browsers. +2. Inherit line-height from `html` so users can set them as a class directly on the `html` element. +*/ + +body { + margin: 0; + /* 1 */ + line-height: inherit; + /* 2 */ +} + +/* +1. Add the correct height in Firefox. +2. Correct the inheritance of border color in Firefox. (https://bugzilla.mozilla.org/show_bug.cgi?id=190655) +3. Ensure horizontal rules are visible by default. +*/ + +hr { + height: 0; + /* 1 */ + color: inherit; + /* 2 */ + border-top-width: 1px; + /* 3 */ +} + +/* +Add the correct text decoration in Chrome, Edge, and Safari. +*/ + +abbr:where([title]) { + -webkit-text-decoration: underline dotted; + text-decoration: underline dotted; +} + +/* +Remove the default font size and weight for headings. +*/ + +h1, +h2, +h3, +h4, +h5, +h6 { + font-size: inherit; + font-weight: inherit; +} + +/* +Reset links to optimize for opt-in styling instead of opt-out. +*/ + +a { + color: inherit; + text-decoration: inherit; +} + +/* +Add the correct font weight in Edge and Safari. +*/ + +b, +strong { + font-weight: bolder; +} + +/* +1. Use the user's configured `mono` font family by default. +2. Correct the odd `em` font sizing in all browsers. +*/ + +code, +kbd, +samp, +pre { + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; + /* 1 */ + font-size: 1em; + /* 2 */ +} + +/* +Add the correct font size in all browsers. +*/ + +small { + font-size: 80%; +} + +/* +Prevent `sub` and `sup` elements from affecting the line height in all browsers. +*/ + +sub, +sup { + font-size: 75%; + line-height: 0; + position: relative; + vertical-align: baseline; +} + +sub { + bottom: -0.25em; +} + +sup { + top: -0.5em; +} + +/* +1. Remove text indentation from table contents in Chrome and Safari. (https://bugs.chromium.org/p/chromium/issues/detail?id=999088, https://bugs.webkit.org/show_bug.cgi?id=201297) +2. Correct table border color inheritance in all Chrome and Safari. (https://bugs.chromium.org/p/chromium/issues/detail?id=935729, https://bugs.webkit.org/show_bug.cgi?id=195016) +3. Remove gaps between table borders by default. +*/ + +table { + text-indent: 0; + /* 1 */ + border-color: inherit; + /* 2 */ + border-collapse: collapse; + /* 3 */ +} + +/* +1. Change the font styles in all browsers. +2. Remove the margin in Firefox and Safari. +3. Remove default padding in all browsers. +*/ + +button, +input, +optgroup, +select, +textarea { + font-family: inherit; + /* 1 */ + font-size: 100%; + /* 1 */ + font-weight: inherit; + /* 1 */ + line-height: inherit; + /* 1 */ + color: inherit; + /* 1 */ + margin: 0; + /* 2 */ + padding: 0; + /* 3 */ +} + +/* +Remove the inheritance of text transform in Edge and Firefox. +*/ + +button, +select { + text-transform: none; +} + +/* +1. Correct the inability to style clickable types in iOS and Safari. +2. Remove default button styles. +*/ + +button, +[type='button'], +[type='reset'], +[type='submit'] { + -webkit-appearance: button; + /* 1 */ + background-color: transparent; + /* 2 */ + background-image: none; + /* 2 */ +} + +/* +Use the modern Firefox focus style for all focusable elements. +*/ + +:-moz-focusring { + outline: auto; +} + +/* +Remove the additional `:invalid` styles in Firefox. (https://github.com/mozilla/gecko-dev/blob/2f9eacd9d3d995c937b4251a5557d95d494c9be1/layout/style/res/forms.css#L728-L737) +*/ + +:-moz-ui-invalid { + box-shadow: none; +} + +/* +Add the correct vertical alignment in Chrome and Firefox. +*/ + +progress { + vertical-align: baseline; +} + +/* +Correct the cursor style of increment and decrement buttons in Safari. +*/ + +::-webkit-inner-spin-button, +::-webkit-outer-spin-button { + height: auto; +} + +/* +1. Correct the odd appearance in Chrome and Safari. +2. Correct the outline style in Safari. +*/ + +[type='search'] { + -webkit-appearance: textfield; + /* 1 */ + outline-offset: -2px; + /* 2 */ +} + +/* +Remove the inner padding in Chrome and Safari on macOS. +*/ + +::-webkit-search-decoration { + -webkit-appearance: none; +} + +/* +1. Correct the inability to style clickable types in iOS and Safari. +2. Change font properties to `inherit` in Safari. +*/ + +::-webkit-file-upload-button { + -webkit-appearance: button; + /* 1 */ + font: inherit; + /* 2 */ +} + +/* +Add the correct display in Chrome and Safari. +*/ + +summary { + display: list-item; +} + +/* +Removes the default spacing and border for appropriate elements. +*/ + +blockquote, +dl, +dd, +h1, +h2, +h3, +h4, +h5, +h6, +hr, +figure, +p, +pre { + margin: 0; +} + +fieldset { + margin: 0; + padding: 0; +} + +legend { + padding: 0; +} + +ol, +ul, +menu { + list-style: none; + margin: 0; + padding: 0; +} + +/* +Prevent resizing textareas horizontally by default. +*/ + +textarea { + resize: vertical; +} + +/* +1. Reset the default placeholder opacity in Firefox. (https://github.com/tailwindlabs/tailwindcss/issues/3300) +2. Set the default placeholder color to the user's configured gray 400 color. +*/ + +input::-moz-placeholder, textarea::-moz-placeholder { + opacity: 1; + /* 1 */ + color: #9ca3af; + /* 2 */ +} + +input::placeholder, +textarea::placeholder { + opacity: 1; + /* 1 */ + color: #9ca3af; + /* 2 */ +} + +/* +Set the default cursor for buttons. +*/ + +button, +[role="button"] { + cursor: pointer; +} + +/* +Make sure disabled buttons don't get the pointer cursor. +*/ + +:disabled { + cursor: default; +} + +/* +1. Make replaced elements `display: block` by default. (https://github.com/mozdevs/cssremedy/issues/14) +2. Add `vertical-align: middle` to align replaced elements more sensibly by default. (https://github.com/jensimmons/cssremedy/issues/14#issuecomment-634934210) + This can trigger a poorly considered lint error in some tools but is included by design. +*/ + +img, +svg, +video, +canvas, +audio, +iframe, +embed, +object { + display: block; + /* 1 */ + vertical-align: middle; + /* 2 */ +} + +/* +Constrain images and videos to the parent width and preserve their intrinsic aspect ratio. (https://github.com/mozdevs/cssremedy/issues/14) +*/ + +img, +video { + max-width: 100%; + height: auto; +} + +/* Make elements with the HTML hidden attribute stay hidden by default */ + +[hidden] { + display: none; +} + +*, ::before, ::after { + --tw-border-spacing-x: 0; + --tw-border-spacing-y: 0; + --tw-translate-x: 0; + --tw-translate-y: 0; + --tw-rotate: 0; + --tw-skew-x: 0; + --tw-skew-y: 0; + --tw-scale-x: 1; + --tw-scale-y: 1; + --tw-pan-x: ; + --tw-pan-y: ; + --tw-pinch-zoom: ; + --tw-scroll-snap-strictness: proximity; + --tw-ordinal: ; + --tw-slashed-zero: ; + --tw-numeric-figure: ; + --tw-numeric-spacing: ; + --tw-numeric-fraction: ; + --tw-ring-inset: ; + --tw-ring-offset-width: 0px; + --tw-ring-offset-color: #fff; + --tw-ring-color: rgb(59 130 246 / 0.5); + --tw-ring-offset-shadow: 0 0 #0000; + --tw-ring-shadow: 0 0 #0000; + --tw-shadow: 0 0 #0000; + --tw-shadow-colored: 0 0 #0000; + --tw-blur: ; + --tw-brightness: ; + --tw-contrast: ; + --tw-grayscale: ; + --tw-hue-rotate: ; + --tw-invert: ; + --tw-saturate: ; + --tw-sepia: ; + --tw-drop-shadow: ; + --tw-backdrop-blur: ; + --tw-backdrop-brightness: ; + --tw-backdrop-contrast: ; + --tw-backdrop-grayscale: ; + --tw-backdrop-hue-rotate: ; + --tw-backdrop-invert: ; + --tw-backdrop-opacity: ; + --tw-backdrop-saturate: ; + --tw-backdrop-sepia: ; +} + +::backdrop { + --tw-border-spacing-x: 0; + --tw-border-spacing-y: 0; + --tw-translate-x: 0; + --tw-translate-y: 0; + --tw-rotate: 0; + --tw-skew-x: 0; + --tw-skew-y: 0; + --tw-scale-x: 1; + --tw-scale-y: 1; + --tw-pan-x: ; + --tw-pan-y: ; + --tw-pinch-zoom: ; + --tw-scroll-snap-strictness: proximity; + --tw-ordinal: ; + --tw-slashed-zero: ; + --tw-numeric-figure: ; + --tw-numeric-spacing: ; + --tw-numeric-fraction: ; + --tw-ring-inset: ; + --tw-ring-offset-width: 0px; + --tw-ring-offset-color: #fff; + --tw-ring-color: rgb(59 130 246 / 0.5); + --tw-ring-offset-shadow: 0 0 #0000; + --tw-ring-shadow: 0 0 #0000; + --tw-shadow: 0 0 #0000; + --tw-shadow-colored: 0 0 #0000; + --tw-blur: ; + --tw-brightness: ; + --tw-contrast: ; + --tw-grayscale: ; + --tw-hue-rotate: ; + --tw-invert: ; + --tw-saturate: ; + --tw-sepia: ; + --tw-drop-shadow: ; + --tw-backdrop-blur: ; + --tw-backdrop-brightness: ; + --tw-backdrop-contrast: ; + --tw-backdrop-grayscale: ; + --tw-backdrop-hue-rotate: ; + --tw-backdrop-invert: ; + --tw-backdrop-opacity: ; + --tw-backdrop-saturate: ; + --tw-backdrop-sepia: ; +} + +.container { + width: 100%; +} + +@media (min-width: 640px) { + .container { + max-width: 640px; + } +} + +@media (min-width: 768px) { + .container { + max-width: 768px; + } +} + +@media (min-width: 1024px) { + .container { + max-width: 1024px; + } +} + +@media (min-width: 1280px) { + .container { + max-width: 1280px; + } +} + +@media (min-width: 1536px) { + .container { + max-width: 1536px; + } +} + +.mx-auto { + margin-left: auto; + margin-right: auto; +} + +.mb-16 { + margin-bottom: 4rem; +} + +.mb-4 { + margin-bottom: 1rem; +} + +.mb-8 { + margin-bottom: 2rem; +} + +.ml-1 { + margin-left: 0.25rem; +} + +.ml-3 { + margin-left: 0.75rem; +} + +.ml-4 { + margin-left: 1rem; +} + +.mr-5 { + margin-right: 1.25rem; +} + +.mt-4 { + margin-top: 1rem; +} + +.flex { + display: flex; +} + +.inline-flex { + display: inline-flex; +} + +.hidden { + display: none; +} + +.h-10 { + height: 2.5rem; +} + +.h-4 { + height: 1rem; +} + +.w-10 { + width: 2.5rem; +} + +.w-4 { + width: 1rem; +} + +.w-5\/6 { + width: 83.333333%; +} + +.flex-col { + flex-direction: column; +} + +.flex-wrap { + flex-wrap: wrap; +} + +.items-center { + align-items: center; +} + +.justify-center { + justify-content: center; +} + +.rounded { + border-radius: 0.25rem; +} + +.rounded-full { + border-radius: 9999px; +} + +.border-0 { + border-width: 0px; +} + +.bg-gray-800 { + --tw-bg-opacity: 1; + background-color: rgb(31 41 55 / var(--tw-bg-opacity)); +} + +.bg-gray-900 { + --tw-bg-opacity: 1; + background-color: rgb(17 24 39 / var(--tw-bg-opacity)); +} + +.bg-indigo-500 { + --tw-bg-opacity: 1; + background-color: rgb(99 102 241 / var(--tw-bg-opacity)); +} + +.object-cover { + -o-object-fit: cover; + object-fit: cover; +} + +.object-center { + -o-object-position: center; + object-position: center; +} + +.p-2 { + padding: 0.5rem; +} + +.p-5 { + padding: 1.25rem; +} + +.px-3 { + padding-left: 0.75rem; + padding-right: 0.75rem; +} + +.px-5 { + padding-left: 1.25rem; + padding-right: 1.25rem; +} + +.px-6 { + padding-left: 1.5rem; + padding-right: 1.5rem; +} + +.py-1 { + padding-top: 0.25rem; + padding-bottom: 0.25rem; +} + +.py-2 { + padding-top: 0.5rem; + padding-bottom: 0.5rem; +} + +.py-24 { + padding-top: 6rem; + padding-bottom: 6rem; +} + +.text-center { + text-align: center; +} + +.text-3xl { + font-size: 1.875rem; + line-height: 2.25rem; +} + +.text-base { + font-size: 1rem; + line-height: 1.5rem; +} + +.text-lg { + font-size: 1.125rem; + line-height: 1.75rem; +} + +.text-xl { + font-size: 1.25rem; + line-height: 1.75rem; +} + +.font-medium { + font-weight: 500; +} + +.leading-relaxed { + line-height: 1.625; +} + +.text-gray-400 { + --tw-text-opacity: 1; + color: rgb(156 163 175 / var(--tw-text-opacity)); +} + +.text-white { + --tw-text-opacity: 1; + color: rgb(255 255 255 / var(--tw-text-opacity)); +} + +.hover\:bg-gray-700:hover { + --tw-bg-opacity: 1; + background-color: rgb(55 65 81 / var(--tw-bg-opacity)); +} + +.hover\:bg-indigo-600:hover { + --tw-bg-opacity: 1; + background-color: rgb(79 70 229 / var(--tw-bg-opacity)); +} + +.hover\:text-white:hover { + --tw-text-opacity: 1; + color: rgb(255 255 255 / var(--tw-text-opacity)); +} + +.focus\:outline-none:focus { + outline: 2px solid transparent; + outline-offset: 2px; +} + +@media (min-width: 640px) { + .sm\:text-4xl { + font-size: 2.25rem; + line-height: 2.5rem; + } +} + +@media (min-width: 768px) { + .md\:mb-0 { + margin-bottom: 0px; + } + + .md\:ml-auto { + margin-left: auto; + } + + .md\:mt-0 { + margin-top: 0px; + } + + .md\:w-1\/2 { + width: 50%; + } + + .md\:flex-row { + flex-direction: row; + } + + .md\:items-start { + align-items: flex-start; + } + + .md\:pr-16 { + padding-right: 4rem; + } + + .md\:text-left { + text-align: left; + } +} + +@media (min-width: 1024px) { + .lg\:inline-block { + display: inline-block; + } + + .lg\:w-full { + width: 100%; + } + + .lg\:max-w-lg { + max-width: 32rem; + } + + .lg\:flex-grow { + flex-grow: 1; + } + + .lg\:pr-24 { + padding-right: 6rem; + } +} \ No newline at end of file diff --git a/examples/tailwind.rs b/examples/tailwind/src/main.rs similarity index 90% rename from examples/tailwind.rs rename to examples/tailwind/src/main.rs index af6bc4c4f..2dbb183c9 100644 --- a/examples/tailwind.rs +++ b/examples/tailwind/src/main.rs @@ -1,22 +1,16 @@ #![allow(non_snake_case)] -//! Example: Basic Tailwind usage -//! -//! This example shows how an app might be styled with TailwindCSS. -//! -//! To minify your tailwind bundle, currently you need to use npm. Follow these instructions: -//! -//! https://dev.to/arctic_hen7/how-to-set-up-tailwind-css-with-yew-and-trunk-il9 - use dioxus::prelude::*; -use dioxus_desktop::Config; fn main() { + #[cfg(not(target_arch = "wasm32"))] dioxus_desktop::launch_cfg( app, - Config::new() - .with_custom_head("".to_string()), + dioxus_desktop::Config::new() + .with_custom_head(r#""#.to_string()), ); + #[cfg(target_arch = "wasm32")] + dioxus_web::launch(app); } pub fn app(cx: Scope) -> Element { diff --git a/examples/tailwind/tailwind.config.js b/examples/tailwind/tailwind.config.js new file mode 100644 index 000000000..2a69d5803 --- /dev/null +++ b/examples/tailwind/tailwind.config.js @@ -0,0 +1,9 @@ +/** @type {import('tailwindcss').Config} */ +module.exports = { + mode: "all", + content: ["./src/**/*.{rs,html,css}", "./dist/**/*.html"], + theme: { + extend: {}, + }, + plugins: [], +}; diff --git a/examples/web_component.rs b/examples/web_component.rs new file mode 100644 index 000000000..bd0f7eb15 --- /dev/null +++ b/examples/web_component.rs @@ -0,0 +1,13 @@ +use dioxus::prelude::*; + +fn main() { + dioxus_desktop::launch(app); +} + +fn app(cx: Scope) -> Element { + cx.render(rsx! { + web-component { + "my-prop": "5%", + } + }) +} diff --git a/notes/CONTRIBUTING.md b/notes/CONTRIBUTING.md index e69de29bb..150b60c88 100644 --- a/notes/CONTRIBUTING.md +++ b/notes/CONTRIBUTING.md @@ -0,0 +1,13 @@ +# Contributing + +On Linux, install the following packages: + +```bash +sudo apt install libgdk3.0-cil libatk1.0-dev libcairo2-dev libpango1.0-dev libgdk-pixbuf2.0-dev libsoup-3.0-dev libjavascriptcoregtk-4.1-dev libwebkit2gtk-4.1-dev +``` + +Then run: + +```bash +cargo test --workspace --tests +``` diff --git a/packages/autofmt/src/buffer.rs b/packages/autofmt/src/buffer.rs index 26c47f54b..fea5a4a3f 100644 --- a/packages/autofmt/src/buffer.rs +++ b/packages/autofmt/src/buffer.rs @@ -8,6 +8,8 @@ use std::fmt::{Result, Write}; use dioxus_rsx::IfmtInput; +use crate::write_ifmt; + /// The output buffer that tracks indent and string #[derive(Debug, Default)] pub struct Buffer { @@ -48,7 +50,7 @@ impl Buffer { } pub fn write_text(&mut self, text: &IfmtInput) -> Result { - write!(self.buf, "\"{}\"", text.source.as_ref().unwrap().value()) + write_ifmt(text, &mut self.buf) } } diff --git a/packages/autofmt/src/component.rs b/packages/autofmt/src/component.rs index 89ebe2229..d5d92b79a 100644 --- a/packages/autofmt/src/component.rs +++ b/packages/autofmt/src/component.rs @@ -1,4 +1,4 @@ -use crate::{writer::Location, Writer}; +use crate::{ifmt_to_string, writer::Location, Writer}; use dioxus_rsx::*; use quote::ToTokens; use std::fmt::{Result, Write}; @@ -170,9 +170,9 @@ impl Writer<'_> { ContentField::Formatted(s) => { write!( self.out, - "{}: \"{}\"", + "{}: {}", name, - s.source.as_ref().unwrap().value() + s.source.as_ref().unwrap().to_token_stream() )?; } ContentField::OnHandlerRaw(exp) => { @@ -215,7 +215,7 @@ impl Writer<'_> { let attr_len = fields .iter() .map(|field| match &field.content { - ContentField::Formatted(s) => s.source.as_ref().unwrap().value().len() , + ContentField::Formatted(s) => ifmt_to_string(s).len() , ContentField::OnHandlerRaw(exp) | ContentField::ManExpr(exp) => { let formatted = prettyplease::unparse_expr(exp); let len = if formatted.contains('\n') { diff --git a/packages/autofmt/src/element.rs b/packages/autofmt/src/element.rs index 5bb7d42fc..50a991897 100644 --- a/packages/autofmt/src/element.rs +++ b/packages/autofmt/src/element.rs @@ -1,6 +1,7 @@ -use crate::Writer; +use crate::{ifmt_to_string, Writer}; use dioxus_rsx::*; use proc_macro2::Span; +use quote::ToTokens; use std::{ fmt::Result, fmt::{self, Write}, @@ -175,11 +176,7 @@ impl Writer<'_> { if !sameline { self.out.indented_tabbed_line()?; } - write!( - self.out, - "key: \"{}\"", - key.source.as_ref().unwrap().value() - )?; + write!(self.out, "key: {}", ifmt_to_string(key))?; if !attributes.is_empty() { write!(self.out, ",")?; if sameline { @@ -216,11 +213,7 @@ impl Writer<'_> { fn write_attribute(&mut self, attr: &ElementAttrNamed) -> Result { match &attr.attr { ElementAttr::AttrText { name, value } => { - write!( - self.out, - "{name}: \"{value}\"", - value = value.source.as_ref().unwrap().value() - )?; + write!(self.out, "{name}: {value}", value = ifmt_to_string(value))?; } ElementAttr::AttrExpression { name, value } => { let out = prettyplease::unparse_expr(value); @@ -230,15 +223,15 @@ impl Writer<'_> { ElementAttr::CustomAttrText { name, value } => { write!( self.out, - "\"{name}\": \"{value}\"", - name = name.value(), - value = value.source.as_ref().unwrap().value() + "{name}: {value}", + name = name.to_token_stream(), + value = ifmt_to_string(value) )?; } ElementAttr::CustomAttrExpression { name, value } => { let out = prettyplease::unparse_expr(value); - write!(self.out, "\"{}\": {}", name.value(), out)?; + write!(self.out, "{}: {}", name.to_token_stream(), out)?; } ElementAttr::EventTokens { name, tokens } => { @@ -315,7 +308,7 @@ impl Writer<'_> { } match children { - [BodyNode::Text(ref text)] => Some(text.source.as_ref().unwrap().value().len()), + [BodyNode::Text(ref text)] => Some(ifmt_to_string(text).len()), [BodyNode::Component(ref comp)] => { let attr_len = self.field_len(&comp.fields, &comp.manual_props); @@ -338,7 +331,7 @@ impl Writer<'_> { if el.children.len() == 1 { if let BodyNode::Text(ref text) = el.children[0] { - let value = text.source.as_ref().unwrap().value(); + let value = ifmt_to_string(text); if value.len() + el.name.to_string().len() + attr_len < 80 { return Some(value.len() + el.name.to_string().len() + attr_len); @@ -356,7 +349,7 @@ impl Writer<'_> { match item { BodyNode::Component(_) | BodyNode::Element(_) => return None, BodyNode::Text(text) => { - total_count += text.source.as_ref().unwrap().value().len() + total_count += ifmt_to_string(text).len(); } BodyNode::RawExpr(expr) => match get_expr_length(expr) { Some(len) => total_count += len, diff --git a/packages/autofmt/src/lib.rs b/packages/autofmt/src/lib.rs index 12bf00585..0d2426a0b 100644 --- a/packages/autofmt/src/lib.rs +++ b/packages/autofmt/src/lib.rs @@ -1,7 +1,10 @@ +use std::fmt::{Display, Write}; + use crate::writer::*; use collect_macros::byte_offset; -use dioxus_rsx::{BodyNode, CallBody}; +use dioxus_rsx::{BodyNode, CallBody, IfmtInput}; use proc_macro2::LineColumn; +use quote::ToTokens; use syn::{ExprMacro, MacroDelimiter}; mod buffer; @@ -130,8 +133,6 @@ pub fn write_block_out(body: CallBody) -> Option { } fn write_body(buf: &mut Writer, body: &CallBody) { - use std::fmt::Write; - if buf.is_short_children(&body.roots).is_some() { // write all the indents with spaces and commas between for idx in 0..body.roots.len() - 1 { @@ -208,3 +209,24 @@ pub fn apply_formats(input: &str, blocks: Vec) -> String { out } + +struct DisplayIfmt<'a>(&'a IfmtInput); + +impl Display for DisplayIfmt<'_> { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let inner_tokens = self.0.source.as_ref().unwrap().to_token_stream(); + inner_tokens.fmt(f) + } +} + +pub(crate) fn ifmt_to_string(input: &IfmtInput) -> String { + let mut buf = String::new(); + let display = DisplayIfmt(input); + write!(&mut buf, "{}", display).unwrap(); + buf +} + +pub(crate) fn write_ifmt(input: &IfmtInput, writable: &mut impl Write) -> std::fmt::Result { + let display = DisplayIfmt(input); + write!(writable, "{}", display) +} diff --git a/packages/autofmt/src/writer.rs b/packages/autofmt/src/writer.rs index 6a0e38d82..59f135592 100644 --- a/packages/autofmt/src/writer.rs +++ b/packages/autofmt/src/writer.rs @@ -8,6 +8,7 @@ use std::{ use syn::{spanned::Spanned, Expr, ExprIf}; use crate::buffer::Buffer; +use crate::ifmt_to_string; #[derive(Debug)] pub struct Writer<'a> { @@ -147,16 +148,16 @@ impl<'a> Writer<'a> { total += match &attr.attr { ElementAttr::AttrText { value, name } => { - value.source.as_ref().unwrap().value().len() + name.span().line_length() + 6 + ifmt_to_string(value).len() + name.span().line_length() + 6 } ElementAttr::AttrExpression { name, value } => { value.span().line_length() + name.span().line_length() + 6 } ElementAttr::CustomAttrText { value, name } => { - value.source.as_ref().unwrap().value().len() + name.value().len() + 6 + ifmt_to_string(value).len() + name.to_token_stream().to_string().len() + 6 } ElementAttr::CustomAttrExpression { name, value } => { - name.value().len() + value.span().line_length() + 6 + name.to_token_stream().to_string().len() + value.span().line_length() + 6 } ElementAttr::EventTokens { tokens, name } => { let location = Location::new(tokens.span().start()); diff --git a/packages/autofmt/tests/samples.rs b/packages/autofmt/tests/samples.rs index 19e636926..8145b0e8e 100644 --- a/packages/autofmt/tests/samples.rs +++ b/packages/autofmt/tests/samples.rs @@ -4,8 +4,8 @@ macro_rules! twoway { // doc attrs $( #[doc = $doc:expr] )* - $name:ident - ),* + $name:ident, + )* ) => { $( $( #[doc = $doc] )* @@ -14,6 +14,9 @@ macro_rules! twoway { let src = include_str!(concat!("./samples/", stringify!($name), ".rsx")); let formatted = dioxus_autofmt::fmt_file(src); let out = dioxus_autofmt::apply_formats(src, formatted); + // normalize line endings + let out = out.replace("\r", ""); + let src = src.replace("\r", ""); pretty_assertions::assert_eq!(&src, &out); } )* @@ -21,24 +24,25 @@ macro_rules! twoway { } twoway![ - simple, - comments, attributes, - manual_props, + collapse_expr, + comments, + commentshard, complex, + emoji, + ifchain_forloop, + immediate_expr, + key, + long_exprs, + long, + manual_props, + messy_indent, + multirsx, + raw_strings, + reallylong, + simple, + t2, tiny, tinynoopt, - long, - key, - multirsx, - commentshard, - emoji, - messy_indent, - long_exprs, - ifchain_forloop, - t2, - reallylong, - immediate_expr, - collapse_expr, - trailing_expr + trailing_expr, ]; diff --git a/packages/autofmt/tests/samples/complex.rsx b/packages/autofmt/tests/samples/complex.rsx index 598d08a82..1bf29a767 100644 --- a/packages/autofmt/tests/samples/complex.rsx +++ b/packages/autofmt/tests/samples/complex.rsx @@ -13,7 +13,9 @@ rsx! { // Complex nesting with handlers li { - Link { class: "flex items-center pl-3 py-3 pr-4 {active_class} rounded", to: "{to}", + Link { + class: "flex items-center pl-3 py-3 pr-4 {active_class} rounded", + to: "{to}", span { class: "inline-block mr-3", icons::icon_0 {} } span { "{name}" } children.is_some().then(|| rsx! { diff --git a/packages/autofmt/tests/samples/raw_strings.rsx b/packages/autofmt/tests/samples/raw_strings.rsx new file mode 100644 index 000000000..8a4d0c38f --- /dev/null +++ b/packages/autofmt/tests/samples/raw_strings.rsx @@ -0,0 +1,12 @@ +rsx! { + // Raw text strings + button { r#"Click me"#, r##"Click me"##, r######"Click me"######, r#"dynamic {1}"# } + + // Raw attribute strings + div { + width: r#"10px"#, + height: r##"{10}px"##, + "raw-attr": r###"raw-attr"###, + "raw-attr2": r###"{100}"### + } +} diff --git a/packages/autofmt/tests/wrong.rs b/packages/autofmt/tests/wrong.rs index acf930b40..5a0fb8a87 100644 --- a/packages/autofmt/tests/wrong.rs +++ b/packages/autofmt/tests/wrong.rs @@ -6,6 +6,11 @@ macro_rules! twoway { let src_wrong = include_str!(concat!("./wrong/", $val, ".wrong.rsx")); let formatted = dioxus_autofmt::fmt_file(src_wrong); let out = dioxus_autofmt::apply_formats(src_wrong, formatted); + + // normalize line endings + let out = out.replace("\r", ""); + let src_right = src_right.replace("\r", ""); + pretty_assertions::assert_eq!(&src_right, &out); } }; diff --git a/packages/core/src/diff.rs b/packages/core/src/diff.rs index 56a2d05de..a8b913440 100644 --- a/packages/core/src/diff.rs +++ b/packages/core/src/diff.rs @@ -7,7 +7,7 @@ use crate::{ nodes::{DynamicNode, VNode}, scopes::ScopeId, virtual_dom::VirtualDom, - Attribute, AttributeValue, TemplateNode, + Attribute, TemplateNode, }; use rustc_hash::{FxHashMap, FxHashSet}; @@ -125,10 +125,8 @@ impl<'b> VirtualDom { .mounted_element .set(left_attr.mounted_element.get()); - // We want to make sure anything listener that gets pulled is valid - if let AttributeValue::Listener(_) = right_attr.value { - self.update_template(left_attr.mounted_element.get(), right_template); - } + // We want to make sure anything that gets pulled is valid + self.update_template(left_attr.mounted_element.get(), right_template); // If the attributes are different (or volatile), we need to update them if left_attr.value != right_attr.value || left_attr.volatile { diff --git a/packages/core/src/nodes.rs b/packages/core/src/nodes.rs index da139e78a..dabb479cb 100644 --- a/packages/core/src/nodes.rs +++ b/packages/core/src/nodes.rs @@ -284,6 +284,17 @@ where Ok(&*Box::leak(deserialized)) } +#[cfg(feature = "serialize")] +fn deserialize_option_leaky<'a, 'de, D>(deserializer: D) -> Result, D::Error> +where + D: serde::Deserializer<'de>, +{ + use serde::Deserialize; + + let deserialized = Option::::deserialize(deserializer)?; + Ok(deserialized.map(|deserialized| &*Box::leak(deserialized.into_boxed_str()))) +} + impl<'a> Template<'a> { /// Is this template worth caching at all, since it's completely runtime? /// @@ -319,6 +330,10 @@ pub enum TemplateNode<'a> { /// /// In HTML, this would be a valid URI that defines a namespace for all elements below it /// SVG is an example of this namespace + #[cfg_attr( + feature = "serialize", + serde(deserialize_with = "deserialize_option_leaky") + )] namespace: Option<&'a str>, /// A list of possibly dynamic attribues for this element diff --git a/packages/core/src/virtual_dom.rs b/packages/core/src/virtual_dom.rs index da5526b32..fe4d3e35b 100644 --- a/packages/core/src/virtual_dom.rs +++ b/packages/core/src/virtual_dom.rs @@ -389,64 +389,70 @@ impl VirtualDom { // Loop through each dynamic attribute (in a depth first order) in this template before moving up to the template's parent. while let Some(el_ref) = parent_path { // safety: we maintain references of all vnodes in the element slab - let template = unsafe { el_ref.template.unwrap().as_ref() }; - let node_template = template.template.get(); - let target_path = el_ref.path; + if let Some(template) = el_ref.template { + let template = unsafe { template.as_ref() }; + let node_template = template.template.get(); + let target_path = el_ref.path; - for (idx, attr) in template.dynamic_attrs.iter().enumerate() { - let this_path = node_template.attr_paths[idx]; + for (idx, attr) in template.dynamic_attrs.iter().enumerate() { + let this_path = node_template.attr_paths[idx]; - // Remove the "on" prefix if it exists, TODO, we should remove this and settle on one - if attr.name.trim_start_matches("on") == name - && target_path.is_decendant(&this_path) - { - listeners.push(&attr.value); + // Remove the "on" prefix if it exists, TODO, we should remove this and settle on one + if attr.name.trim_start_matches("on") == name + && target_path.is_decendant(&this_path) + { + listeners.push(&attr.value); - // Break if this is the exact target element. - // This means we won't call two listeners with the same name on the same element. This should be - // documented, or be rejected from the rsx! macro outright - if target_path == this_path { - break; + // Break if this is the exact target element. + // This means we won't call two listeners with the same name on the same element. This should be + // documented, or be rejected from the rsx! macro outright + if target_path == this_path { + break; + } } } - } - // Now that we've accumulated all the parent attributes for the target element, call them in reverse order - // We check the bubble state between each call to see if the event has been stopped from bubbling - for listener in listeners.drain(..).rev() { - if let AttributeValue::Listener(listener) = listener { - if let Some(cb) = listener.borrow_mut().as_deref_mut() { - cb(uievent.clone()); - } + // Now that we've accumulated all the parent attributes for the target element, call them in reverse order + // We check the bubble state between each call to see if the event has been stopped from bubbling + for listener in listeners.drain(..).rev() { + if let AttributeValue::Listener(listener) = listener { + if let Some(cb) = listener.borrow_mut().as_deref_mut() { + cb(uievent.clone()); + } - if !uievent.propagates.get() { - return; + if !uievent.propagates.get() { + return; + } } } - } - parent_path = template.parent.and_then(|id| self.elements.get(id.0)); + parent_path = dbg!(template.parent).and_then(|id| self.elements.get(id.0)); + } else { + break; + } } } else { // Otherwise, we just call the listener on the target element if let Some(el_ref) = parent_path { // safety: we maintain references of all vnodes in the element slab - let template = unsafe { el_ref.template.unwrap().as_ref() }; - let node_template = template.template.get(); - let target_path = el_ref.path; + if let Some(template) = el_ref.template { + let template = unsafe { template.as_ref() }; + let node_template = template.template.get(); + let target_path = el_ref.path; - for (idx, attr) in template.dynamic_attrs.iter().enumerate() { - let this_path = node_template.attr_paths[idx]; + for (idx, attr) in template.dynamic_attrs.iter().enumerate() { + let this_path = node_template.attr_paths[idx]; - // Remove the "on" prefix if it exists, TODO, we should remove this and settle on one - // Only call the listener if this is the exact target element. - if attr.name.trim_start_matches("on") == name && target_path == this_path { - if let AttributeValue::Listener(listener) = &attr.value { - if let Some(cb) = listener.borrow_mut().as_deref_mut() { - cb(uievent.clone()); + // Remove the "on" prefix if it exists, TODO, we should remove this and settle on one + // Only call the listener if this is the exact target element. + if attr.name.trim_start_matches("on") == name && target_path == this_path { + if let AttributeValue::Listener(listener) = &attr.value { + if let Some(cb) = listener.borrow_mut().as_deref_mut() { + cb(uievent.clone()); + } + + break; } - - break; } } } diff --git a/packages/desktop/Cargo.toml b/packages/desktop/Cargo.toml index 8b41019e1..35e63bd9d 100644 --- a/packages/desktop/Cargo.toml +++ b/packages/desktop/Cargo.toml @@ -38,6 +38,7 @@ slab = "0.4" futures-util = "0.3.25" rfd = "0.11.3" +urlencoding = "2.1.2" [target.'cfg(target_os = "ios")'.dependencies] objc = "0.2.7" @@ -57,4 +58,17 @@ hot-reload = ["dioxus-hot-reload"] [dev-dependencies] dioxus-core-macro = { path = "../core-macro" } dioxus-hooks = { path = "../hooks" } -# image = "0.24.0" # enable this when generating a new desktop image +dioxus = { path = "../dioxus" } +exitcode = "1.1.2" +scraper = "0.16.0" + +# These tests need to be run on the main thread, so they cannot use rust's test harness. +[[test]] +name = "check_events" +path = "headless_tests/events.rs" +harness = false + +[[test]] +name = "check_rendering" +path = "headless_tests/rendering.rs" +harness = false diff --git a/packages/desktop/headless_tests/events.rs b/packages/desktop/headless_tests/events.rs new file mode 100644 index 000000000..0281969ec --- /dev/null +++ b/packages/desktop/headless_tests/events.rs @@ -0,0 +1,351 @@ +use dioxus::html::geometry::euclid::Vector3D; +use dioxus::prelude::*; +use dioxus_desktop::DesktopContext; + +pub(crate) fn check_app_exits(app: Component) { + use dioxus_desktop::tao::window::WindowBuilder; + use dioxus_desktop::Config; + // This is a deadman's switch to ensure that the app exits + let should_panic = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(true)); + let should_panic_clone = should_panic.clone(); + std::thread::spawn(move || { + std::thread::sleep(std::time::Duration::from_secs(100)); + if should_panic_clone.load(std::sync::atomic::Ordering::SeqCst) { + std::process::exit(exitcode::SOFTWARE); + } + }); + + dioxus_desktop::launch_cfg( + app, + Config::new().with_window(WindowBuilder::new().with_visible(false)), + ); + + should_panic.store(false, std::sync::atomic::Ordering::SeqCst); +} + +pub fn main() { + check_app_exits(app); +} + +fn mock_event(cx: &ScopeState, id: &'static str, value: &'static str) { + use_effect(cx, (), |_| { + let desktop_context: DesktopContext = cx.consume_context().unwrap(); + async move { + tokio::time::sleep(std::time::Duration::from_millis(100)).await; + desktop_context.eval(&format!( + r#"let element = document.getElementById('{}'); + // Dispatch a synthetic event + const event = {}; + console.log(element, event); + element.dispatchEvent(event); + "#, + id, value + )); + } + }); +} + +#[allow(deprecated)] +fn app(cx: Scope) -> Element { + let desktop_context: DesktopContext = cx.consume_context().unwrap(); + let recieved_events = use_state(cx, || 0); + + // button + mock_event( + cx, + "button", + r#"new MouseEvent("click", { + view: window, + bubbles: true, + cancelable: true, + button: 0, + })"#, + ); + // mouse_move_div + mock_event( + cx, + "mouse_move_div", + r#"new MouseEvent("mousemove", { + view: window, + bubbles: true, + cancelable: true, + buttons: 2, + })"#, + ); + // mouse_click_div + mock_event( + cx, + "mouse_click_div", + r#"new MouseEvent("click", { + view: window, + bubbles: true, + cancelable: true, + buttons: 2, + button: 2, + })"#, + ); + // mouse_dblclick_div + mock_event( + cx, + "mouse_dblclick_div", + r#"new MouseEvent("dblclick", { + view: window, + bubbles: true, + cancelable: true, + buttons: 1|2, + button: 2, + })"#, + ); + // mouse_down_div + mock_event( + cx, + "mouse_down_div", + r#"new MouseEvent("mousedown", { + view: window, + bubbles: true, + cancelable: true, + buttons: 2, + button: 2, + })"#, + ); + // mouse_up_div + mock_event( + cx, + "mouse_up_div", + r#"new MouseEvent("mouseup", { + view: window, + bubbles: true, + cancelable: true, + buttons: 0, + button: 0, + })"#, + ); + // wheel_div + mock_event( + cx, + "wheel_div", + r#"new WheelEvent("wheel", { + view: window, + deltaX: 1.0, + deltaY: 2.0, + deltaZ: 3.0, + deltaMode: 0x00, + bubbles: true, + })"#, + ); + // key_down_div + mock_event( + cx, + "key_down_div", + r#"new KeyboardEvent("keydown", { + key: "a", + code: "KeyA", + location: 0, + repeat: true, + keyCode: 65, + charCode: 97, + char: "a", + charCode: 0, + altKey: false, + ctrlKey: false, + metaKey: false, + shiftKey: false, + isComposing: false, + which: 65, + bubbles: true, + })"#, + ); + // key_up_div + mock_event( + cx, + "key_up_div", + r#"new KeyboardEvent("keyup", { + key: "a", + code: "KeyA", + location: 0, + repeat: false, + keyCode: 65, + charCode: 97, + char: "a", + charCode: 0, + altKey: false, + ctrlKey: false, + metaKey: false, + shiftKey: false, + isComposing: false, + which: 65, + bubbles: true, + })"#, + ); + // key_press_div + mock_event( + cx, + "key_press_div", + r#"new KeyboardEvent("keypress", { + key: "a", + code: "KeyA", + location: 0, + repeat: false, + keyCode: 65, + charCode: 97, + char: "a", + charCode: 0, + altKey: false, + ctrlKey: false, + metaKey: false, + shiftKey: false, + isComposing: false, + which: 65, + bubbles: true, + })"#, + ); + // focus_in_div + mock_event( + cx, + "focus_in_div", + r#"new FocusEvent("focusin", {bubbles: true})"#, + ); + // focus_out_div + mock_event( + cx, + "focus_out_div", + r#"new FocusEvent("focusout",{bubbles: true})"#, + ); + + if **recieved_events == 12 { + println!("all events recieved"); + desktop_context.close(); + } + + cx.render(rsx! { + div { + button { + id: "button", + onclick: move |event| { + println!("{:?}", event.data); + assert!(event.data.modifiers().is_empty()); + assert!(event.data.held_buttons().is_empty()); + assert_eq!(event.data.trigger_button(), Some(dioxus_html::input_data::MouseButton::Primary)); + recieved_events.modify(|x| *x + 1) + }, + } + div { + id: "mouse_move_div", + onmousemove: move |event| { + println!("{:?}", event.data); + assert!(event.data.modifiers().is_empty()); + assert!(event.data.held_buttons().contains(dioxus_html::input_data::MouseButton::Secondary)); + recieved_events.modify(|x| *x + 1) + }, + } + div { + id: "mouse_click_div", + onclick: move |event| { + println!("{:?}", event.data); + assert!(event.data.modifiers().is_empty()); + assert!(event.data.held_buttons().contains(dioxus_html::input_data::MouseButton::Secondary)); + assert_eq!(event.data.trigger_button(), Some(dioxus_html::input_data::MouseButton::Secondary)); + recieved_events.modify(|x| *x + 1) + }, + } + div{ + id: "mouse_dblclick_div", + ondblclick: move |event| { + println!("{:?}", event.data); + assert!(event.data.modifiers().is_empty()); + assert!(event.data.held_buttons().contains(dioxus_html::input_data::MouseButton::Primary)); + assert!(event.data.held_buttons().contains(dioxus_html::input_data::MouseButton::Secondary)); + assert_eq!(event.data.trigger_button(), Some(dioxus_html::input_data::MouseButton::Secondary)); + recieved_events.modify(|x| *x + 1) + } + } + div{ + id: "mouse_down_div", + onmousedown: move |event| { + println!("{:?}", event.data); + assert!(event.data.modifiers().is_empty()); + assert!(event.data.held_buttons().contains(dioxus_html::input_data::MouseButton::Secondary)); + assert_eq!(event.data.trigger_button(), Some(dioxus_html::input_data::MouseButton::Secondary)); + recieved_events.modify(|x| *x + 1) + } + } + div{ + id: "mouse_up_div", + onmouseup: move |event| { + println!("{:?}", event.data); + assert!(event.data.modifiers().is_empty()); + assert!(event.data.held_buttons().is_empty()); + assert_eq!(event.data.trigger_button(), Some(dioxus_html::input_data::MouseButton::Primary)); + recieved_events.modify(|x| *x + 1) + } + } + div{ + id: "wheel_div", + width: "100px", + height: "100px", + background_color: "red", + onwheel: move |event| { + println!("{:?}", event.data); + let dioxus_html::geometry::WheelDelta::Pixels(delta)= event.data.delta()else{ + panic!("Expected delta to be in pixels") + }; + assert_eq!(delta, Vector3D::new(1.0, 2.0, 3.0)); + recieved_events.modify(|x| *x + 1) + } + } + input{ + id: "key_down_div", + onkeydown: move |event| { + println!("{:?}", event.data); + assert!(event.data.modifiers().is_empty()); + assert_eq!(event.data.key().to_string(), "a"); + assert_eq!(event.data.code().to_string(), "KeyA"); + assert_eq!(event.data.location, 0); + assert!(event.data.is_auto_repeating()); + + recieved_events.modify(|x| *x + 1) + } + } + input{ + id: "key_up_div", + onkeyup: move |event| { + println!("{:?}", event.data); + assert!(event.data.modifiers().is_empty()); + assert_eq!(event.data.key().to_string(), "a"); + assert_eq!(event.data.code().to_string(), "KeyA"); + assert_eq!(event.data.location, 0); + assert!(!event.data.is_auto_repeating()); + + recieved_events.modify(|x| *x + 1) + } + } + input{ + id: "key_press_div", + onkeypress: move |event| { + println!("{:?}", event.data); + assert!(event.data.modifiers().is_empty()); + assert_eq!(event.data.key().to_string(), "a"); + assert_eq!(event.data.code().to_string(), "KeyA"); + assert_eq!(event.data.location, 0); + assert!(!event.data.is_auto_repeating()); + + recieved_events.modify(|x| *x + 1) + } + } + input{ + id: "focus_in_div", + onfocusin: move |event| { + println!("{:?}", event.data); + recieved_events.modify(|x| *x + 1) + } + } + input{ + id: "focus_out_div", + onfocusout: move |event| { + println!("{:?}", event.data); + recieved_events.modify(|x| *x + 1) + } + } + } + }) +} diff --git a/packages/desktop/headless_tests/rendering.rs b/packages/desktop/headless_tests/rendering.rs new file mode 100644 index 000000000..635da9c23 --- /dev/null +++ b/packages/desktop/headless_tests/rendering.rs @@ -0,0 +1,94 @@ +use dioxus::prelude::*; +use dioxus_desktop::DesktopContext; + +pub(crate) fn check_app_exits(app: Component) { + use dioxus_desktop::tao::window::WindowBuilder; + use dioxus_desktop::Config; + // This is a deadman's switch to ensure that the app exits + let should_panic = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(true)); + let should_panic_clone = should_panic.clone(); + std::thread::spawn(move || { + std::thread::sleep(std::time::Duration::from_secs(100)); + if should_panic_clone.load(std::sync::atomic::Ordering::SeqCst) { + std::process::exit(exitcode::SOFTWARE); + } + }); + + dioxus_desktop::launch_cfg( + app, + Config::new().with_window(WindowBuilder::new().with_visible(false)), + ); + + should_panic.store(false, std::sync::atomic::Ordering::SeqCst); +} + +fn main() { + check_app_exits(check_html_renders); +} + +fn use_inner_html(cx: &ScopeState, id: &'static str) -> Option { + let value: &UseRef> = use_ref(cx, || None); + use_effect(cx, (), |_| { + to_owned![value]; + let desktop_context: DesktopContext = cx.consume_context().unwrap(); + async move { + tokio::time::sleep(std::time::Duration::from_millis(100)).await; + let html = desktop_context + .eval(&format!( + r#"let element = document.getElementById('{}'); + return element.innerHTML;"#, + id + )) + .await; + if let Ok(serde_json::Value::String(html)) = html { + println!("html: {}", html); + value.set(Some(html)); + } + } + }); + value.read().clone() +} + +const EXPECTED_HTML: &str = r#"

text

hello world

"#; + +fn check_html_renders(cx: Scope) -> Element { + let inner_html = use_inner_html(cx, "main_div"); + let desktop_context: DesktopContext = cx.consume_context().unwrap(); + + if let Some(raw_html) = inner_html.as_deref() { + let fragment = scraper::Html::parse_fragment(raw_html); + println!("fragment: {:?}", fragment.html()); + let expected = scraper::Html::parse_fragment(EXPECTED_HTML); + println!("fragment: {:?}", expected.html()); + if fragment == expected { + println!("html matches"); + desktop_context.close(); + } + } + + let dyn_value = 0; + let dyn_element = rsx! { + div { + dangerous_inner_html: "

hello world

", + } + }; + + render! { + div { + id: "main_div", + div { + width: "100px", + height: "100px", + color: "rgb({dyn_value}, {dyn_value}, {dyn_value})", + id: 5, + input { + "type": "checkbox", + }, + h1 { + "text" + } + dyn_element + } + } + } +} diff --git a/packages/desktop/src/desktop_context.rs b/packages/desktop/src/desktop_context.rs index 2ed4bbee9..6af6401f3 100644 --- a/packages/desktop/src/desktop_context.rs +++ b/packages/desktop/src/desktop_context.rs @@ -5,6 +5,7 @@ use std::rc::Weak; use crate::create_new_window; use crate::eval::EvalResult; use crate::events::IpcMessage; +use crate::query::QueryEngine; use crate::shortcut::IntoKeyCode; use crate::shortcut::IntoModifersState; use crate::shortcut::ShortcutId; @@ -16,7 +17,6 @@ use dioxus_core::ScopeState; use dioxus_core::VirtualDom; #[cfg(all(feature = "hot-reload", debug_assertions))] use dioxus_hot_reload::HotReloadMsg; -use serde_json::Value; use slab::Slab; use wry::application::event::Event; use wry::application::event_loop::EventLoopProxy; @@ -59,8 +59,8 @@ pub struct DesktopContext { /// The proxy to the event loop pub proxy: ProxyType, - /// The receiver for eval results since eval is async - pub(super) eval: tokio::sync::broadcast::Sender, + /// The receiver for queries about the current window + pub(super) query: QueryEngine, pub(super) pending_windows: WebviewQueue, @@ -96,7 +96,7 @@ impl DesktopContext { webview, proxy, event_loop, - eval: tokio::sync::broadcast::channel(8).0, + query: Default::default(), pending_windows: webviews, event_handlers, shortcut_manager, @@ -210,28 +210,10 @@ impl DesktopContext { /// Evaluate a javascript expression pub fn eval(&self, code: &str) -> EvalResult { - // Embed the return of the eval in a function so we can send it back to the main thread - let script = format!( - r#" - window.ipc.postMessage( - JSON.stringify({{ - "method":"eval_result", - "params": ( - function(){{ - {code} - }} - )() - }}) - ); - "# - ); + // the query id lets us keep track of the eval result and send it back to the main thread + let query = self.query.new_query(code, &self.webview); - if let Err(e) = self.webview.evaluate_script(&script) { - // send an error to the eval receiver - log::warn!("Eval script error: {e}"); - } - - EvalResult::new(self.eval.clone()) + EvalResult::new(query) } /// Create a wry event handler that listens for wry events. diff --git a/packages/desktop/src/element.rs b/packages/desktop/src/element.rs new file mode 100644 index 000000000..94db28f0b --- /dev/null +++ b/packages/desktop/src/element.rs @@ -0,0 +1,123 @@ +use std::rc::Rc; + +use dioxus_core::ElementId; +use dioxus_html::{geometry::euclid::Rect, MountedResult, RenderedElementBacking}; +use wry::webview::WebView; + +use crate::query::QueryEngine; + +/// A mounted element passed to onmounted events +pub struct DesktopElement { + id: ElementId, + webview: Rc, + query: QueryEngine, +} + +impl DesktopElement { + pub(crate) fn new(id: ElementId, webview: Rc, query: QueryEngine) -> Self { + Self { id, webview, query } + } +} + +impl RenderedElementBacking for DesktopElement { + fn get_raw_element(&self) -> dioxus_html::MountedResult<&dyn std::any::Any> { + Ok(self) + } + + fn get_client_rect( + &self, + ) -> std::pin::Pin< + Box< + dyn futures_util::Future< + Output = dioxus_html::MountedResult>, + >, + >, + > { + let script = format!("return window.interpreter.GetClientRect({});", self.id.0); + + let fut = self + .query + .new_query::>>(&script, &self.webview) + .resolve(); + Box::pin(async move { + match fut.await { + Ok(Some(rect)) => Ok(rect), + Ok(None) => MountedResult::Err(dioxus_html::MountedError::OperationFailed( + Box::new(DesktopQueryError::FailedToQuery), + )), + Err(err) => { + MountedResult::Err(dioxus_html::MountedError::OperationFailed(Box::new(err))) + } + } + }) + } + + fn scroll_to( + &self, + behavior: dioxus_html::ScrollBehavior, + ) -> std::pin::Pin>>> { + let script = format!( + "return window.interpreter.ScrollTo({}, {});", + self.id.0, + serde_json::to_string(&behavior).expect("Failed to serialize ScrollBehavior") + ); + + let fut = self + .query + .new_query::(&script, &self.webview) + .resolve(); + Box::pin(async move { + match fut.await { + Ok(true) => Ok(()), + Ok(false) => MountedResult::Err(dioxus_html::MountedError::OperationFailed( + Box::new(DesktopQueryError::FailedToQuery), + )), + Err(err) => { + MountedResult::Err(dioxus_html::MountedError::OperationFailed(Box::new(err))) + } + } + }) + } + + fn set_focus( + &self, + focus: bool, + ) -> std::pin::Pin>>> { + let script = format!( + "return window.interpreter.SetFocus({}, {});", + self.id.0, focus + ); + + let fut = self + .query + .new_query::(&script, &self.webview) + .resolve(); + + Box::pin(async move { + match fut.await { + Ok(true) => Ok(()), + Ok(false) => MountedResult::Err(dioxus_html::MountedError::OperationFailed( + Box::new(DesktopQueryError::FailedToQuery), + )), + Err(err) => { + MountedResult::Err(dioxus_html::MountedError::OperationFailed(Box::new(err))) + } + } + }) + } +} + +#[derive(Debug)] +enum DesktopQueryError { + FailedToQuery, +} + +impl std::fmt::Display for DesktopQueryError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + DesktopQueryError::FailedToQuery => write!(f, "Failed to query the element"), + } + } +} + +impl std::error::Error for DesktopQueryError {} diff --git a/packages/desktop/src/eval.rs b/packages/desktop/src/eval.rs index 7a92bedf5..1e11cad18 100644 --- a/packages/desktop/src/eval.rs +++ b/packages/desktop/src/eval.rs @@ -1,36 +1,32 @@ use std::rc::Rc; +use crate::query::Query; +use crate::query::QueryError; use crate::use_window; use dioxus_core::ScopeState; -use serde::de::Error; use std::future::Future; use std::future::IntoFuture; use std::pin::Pin; /// A future that resolves to the result of a JavaScript evaluation. pub struct EvalResult { - pub(crate) broadcast: tokio::sync::broadcast::Sender, + pub(crate) query: Query, } impl EvalResult { - pub(crate) fn new(sender: tokio::sync::broadcast::Sender) -> Self { - Self { broadcast: sender } + pub(crate) fn new(query: Query) -> Self { + Self { query } } } impl IntoFuture for EvalResult { - type Output = Result; + type Output = Result; - type IntoFuture = Pin>>>; + type IntoFuture = Pin>>>; fn into_future(self) -> Self::IntoFuture { - Box::pin(async move { - let mut reciever = self.broadcast.subscribe(); - match reciever.recv().await { - Ok(result) => Ok(result), - Err(_) => Err(serde_json::Error::custom("No result returned")), - } - }) as Pin>>> + Box::pin(self.query.resolve()) + as Pin>>> } } diff --git a/packages/desktop/src/lib.rs b/packages/desktop/src/lib.rs index 807ebf48e..69161e2a5 100644 --- a/packages/desktop/src/lib.rs +++ b/packages/desktop/src/lib.rs @@ -5,22 +5,27 @@ mod cfg; mod desktop_context; +mod element; mod escape; mod eval; mod events; mod file_upload; mod protocol; +mod query; mod shortcut; mod waker; mod webview; +use crate::query::QueryResult; pub use cfg::Config; pub use desktop_context::{ use_window, use_wry_event_handler, DesktopContext, WryEventHandler, WryEventHandlerId, }; use desktop_context::{EventData, UserWindowEvent, WebviewQueue, WindowEventHandlers}; use dioxus_core::*; +use dioxus_html::MountedData; use dioxus_html::{native_bind::NativeFileEngine, FormData, HtmlEvent}; +use element::DesktopElement; pub use eval::{use_eval, EvalResult}; use futures_util::{pin_mut, FutureExt}; use shortcut::ShortcutRegistry; @@ -221,39 +226,65 @@ pub fn launch_with_props(root: Component

, props: P, cfg: Config) } EventData::Ipc(msg) if msg.method() == "user_event" => { - let evt = match serde_json::from_value::(msg.params()) { + let params = msg.params(); + + let evt = match serde_json::from_value::(params) { Ok(value) => value, Err(_) => return, }; + let HtmlEvent { + element, + name, + bubbles, + data, + } = evt; + let view = webviews.get_mut(&event.1).unwrap(); - view.dom - .handle_event(&evt.name, evt.data.into_any(), evt.element, evt.bubbles); + // check for a mounted event placeholder and replace it with a desktop specific element + let as_any = if let dioxus_html::EventData::Mounted = &data { + let query = view + .dom + .base_scope() + .consume_context::() + .unwrap() + .query; + + let element = DesktopElement::new(element, view.webview.clone(), query); + + Rc::new(MountedData::new(element)) + } else { + data.into_any() + }; + + view.dom.handle_event(&name, as_any, element, bubbles); send_edits(view.dom.render_immediate(), &view.webview); } + // When the webview sends a query, we need to send it to the query manager which handles dispatching the data to the correct pending query + EventData::Ipc(msg) if msg.method() == "query" => { + let params = msg.params(); + + if let Ok(result) = serde_json::from_value::(params) { + let view = webviews.get(&event.1).unwrap(); + let query = view + .dom + .base_scope() + .consume_context::() + .unwrap() + .query; + + query.send(result); + } + } + EventData::Ipc(msg) if msg.method() == "initialize" => { let view = webviews.get_mut(&event.1).unwrap(); send_edits(view.dom.rebuild(), &view.webview); } - // When the webview chirps back with the result of the eval, we send it to the active receiver - // - // This currently doesn't perform any targeting to the callsite, so if you eval multiple times at once, - // you might the wrong result. This should be fixed - EventData::Ipc(msg) if msg.method() == "eval_result" => { - webviews[&event.1] - .dom - .base_scope() - .consume_context::() - .unwrap() - .eval - .send(msg.params()) - .unwrap(); - } - EventData::Ipc(msg) if msg.method() == "browser_open" => { if let Some(temp) = msg.params().as_object() { if temp.contains_key("href") { diff --git a/packages/desktop/src/protocol.rs b/packages/desktop/src/protocol.rs index 2e407b258..6be5d08b4 100644 --- a/packages/desktop/src/protocol.rs +++ b/packages/desktop/src/protocol.rs @@ -1,4 +1,4 @@ -use dioxus_interpreter_js::INTERPRETER_JS; +use dioxus_interpreter_js::{COMMON_JS, INTERPRETER_JS}; use std::{ borrow::Cow, path::{Path, PathBuf}, @@ -37,13 +37,13 @@ fn module_loader(root_name: &str) -> String { ); format!( r#" - @@ -84,10 +84,17 @@ pub(super) fn desktop_handler( .header("Content-Type", "text/html") .body(Cow::from(body)) .map_err(From::from); + } else if request.uri().path() == "/common.js" { + return Response::builder() + .header("Content-Type", "text/javascript") + .body(Cow::from(COMMON_JS.as_bytes())) + .map_err(From::from); } // Else, try to serve a file from the filesystem. - let path = PathBuf::from(request.uri().path().trim_start_matches('/')); + let decoded = urlencoding::decode(request.uri().path().trim_start_matches('/')) + .expect("expected URL to be UTF-8 encoded"); + let path = PathBuf::from(&*decoded); // If the path is relative, we'll try to serve it from the assets directory. let mut asset = get_asset_root() diff --git a/packages/desktop/src/query.rs b/packages/desktop/src/query.rs new file mode 100644 index 000000000..bd8b2ee2a --- /dev/null +++ b/packages/desktop/src/query.rs @@ -0,0 +1,110 @@ +use std::{cell::RefCell, rc::Rc}; + +use serde::{de::DeserializeOwned, Deserialize}; +use serde_json::Value; +use slab::Slab; +use thiserror::Error; +use tokio::sync::broadcast::error::RecvError; +use wry::webview::WebView; + +/// Tracks what query ids are currently active +#[derive(Default, Clone)] +struct SharedSlab { + slab: Rc>>, +} + +/// Handles sending and receiving arbitrary queries from the webview. Queries can be resolved non-sequentially, so we use ids to track them. +#[derive(Clone)] +pub(crate) struct QueryEngine { + sender: Rc>, + active_requests: SharedSlab, +} + +impl Default for QueryEngine { + fn default() -> Self { + let (sender, _) = tokio::sync::broadcast::channel(8); + Self { + sender: Rc::new(sender), + active_requests: SharedSlab::default(), + } + } +} + +impl QueryEngine { + /// Creates a new query and returns a handle to it. The query will be resolved when the webview returns a result with the same id. + pub fn new_query(&self, script: &str, webview: &WebView) -> Query { + let request_id = self.active_requests.slab.borrow_mut().insert(()); + + // start the query + // We embed the return of the eval in a function so we can send it back to the main thread + if let Err(err) = webview.evaluate_script(&format!( + r#"window.ipc.postMessage( + JSON.stringify({{ + "method":"query", + "params": {{ + "id": {request_id}, + "data": (function(){{{script}}})() + }} + }}) + );"# + )) { + log::warn!("Query error: {err}"); + } + + Query { + slab: self.active_requests.clone(), + id: request_id, + reciever: self.sender.subscribe(), + phantom: std::marker::PhantomData, + } + } + + /// Send a query result + pub fn send(&self, data: QueryResult) { + let _ = self.sender.send(data); + } +} + +pub(crate) struct Query { + slab: SharedSlab, + id: usize, + reciever: tokio::sync::broadcast::Receiver, + phantom: std::marker::PhantomData, +} + +impl Query { + /// Resolve the query + pub async fn resolve(mut self) -> Result { + let result = loop { + match self.reciever.recv().await { + Ok(result) => { + if result.id == self.id { + break V::deserialize(result.data).map_err(QueryError::DeserializeError); + } + } + Err(err) => { + break Err(QueryError::RecvError(err)); + } + } + }; + + // Remove the query from the slab + self.slab.slab.borrow_mut().remove(self.id); + + result + } +} + +#[derive(Error, Debug)] +pub enum QueryError { + #[error("Error receiving query result: {0}")] + RecvError(RecvError), + #[error("Error deserializing query result: {0}")] + DeserializeError(serde_json::Error), +} + +#[derive(Clone, Debug, Deserialize)] +pub(crate) struct QueryResult { + id: usize, + data: Value, +} diff --git a/packages/desktop/src/shortcut.rs b/packages/desktop/src/shortcut.rs index 5232bdb8b..e5cc05315 100644 --- a/packages/desktop/src/shortcut.rs +++ b/packages/desktop/src/shortcut.rs @@ -41,17 +41,10 @@ impl Shortcut { impl ShortcutRegistry { pub fn new(target: &EventLoopWindowTarget) -> Self { - let myself = Self { + Self { manager: Rc::new(RefCell::new(ShortcutManager::new(target))), shortcuts: Rc::new(RefCell::new(HashMap::new())), - }; - // prevent CTRL+R from reloading the page which breaks apps - let _ = myself.add_shortcut( - Some(ModifiersState::CONTROL), - KeyCode::KeyR, - Box::new(|| {}), - ); - myself + } } pub(crate) fn call_handlers(&self, id: AcceleratorId) { diff --git a/packages/dioxus-tui/Cargo.toml b/packages/dioxus-tui/Cargo.toml index 43e568b0f..7058036d1 100644 --- a/packages/dioxus-tui/Cargo.toml +++ b/packages/dioxus-tui/Cargo.toml @@ -24,7 +24,7 @@ rink = { path = "../rink" } crossterm = "0.23.0" tokio = { version = "1.15.0", features = ["full"] } futures = "0.3.19" -taffy = "0.2.1" +taffy = "0.3.12" [dev-dependencies] dioxus = { path = "../dioxus" } diff --git a/packages/dioxus-tui/src/element.rs b/packages/dioxus-tui/src/element.rs new file mode 100644 index 000000000..152de4a10 --- /dev/null +++ b/packages/dioxus-tui/src/element.rs @@ -0,0 +1,99 @@ +use std::{ + any::Any, + fmt::{Display, Formatter}, + rc::Rc, +}; + +use dioxus_core::{ElementId, Mutations, VirtualDom}; +use dioxus_html::{ + geometry::euclid::{Point2D, Rect, Size2D}, + MountedData, MountedError, RenderedElementBacking, +}; + +use dioxus_native_core::NodeId; +use rink::query::{ElementRef, Query}; + +pub(crate) fn find_mount_events(mutations: &Mutations) -> Vec { + let mut mount_events = Vec::new(); + for mutation in &mutations.edits { + if let dioxus_core::Mutation::NewEventListener { + name: "mounted", + id, + } = mutation + { + mount_events.push(*id); + } + } + mount_events +} + +// We need to queue the mounted events to give rink time to rendere and resolve the layout of elements after they are created +pub(crate) fn create_mounted_events( + vdom: &VirtualDom, + events: &mut Vec<(ElementId, &'static str, Rc, bool)>, + mount_events: impl Iterator, +) { + let query: Query = vdom + .base_scope() + .consume_context() + .expect("Query should be in context"); + for (id, node_id) in mount_events { + let element = TuiElement { + query: query.clone(), + id: node_id, + }; + events.push((id, "mounted", Rc::new(MountedData::new(element)), false)); + } +} + +struct TuiElement { + query: Query, + id: NodeId, +} + +impl TuiElement { + pub(crate) fn element(&self) -> ElementRef { + self.query.get(self.id) + } +} + +impl RenderedElementBacking for TuiElement { + fn get_client_rect( + &self, + ) -> std::pin::Pin< + Box< + dyn futures::Future< + Output = dioxus_html::MountedResult>, + >, + >, + > { + let layout = self.element().layout(); + Box::pin(async move { + match layout { + Some(layout) => { + let x = layout.location.x as f64; + let y = layout.location.y as f64; + let width = layout.size.width as f64; + let height = layout.size.height as f64; + Ok(Rect::new(Point2D::new(x, y), Size2D::new(width, height))) + } + None => Err(MountedError::OperationFailed(Box::new(TuiElementNotFound))), + } + }) + } + + fn get_raw_element(&self) -> dioxus_html::MountedResult<&dyn std::any::Any> { + Ok(self) + } +} + +#[derive(Debug)] +struct TuiElementNotFound; + +impl Display for TuiElementNotFound { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + write!(f, "TUI element not found") + } +} + +impl std::error::Error for TuiElementNotFound {} diff --git a/packages/dioxus-tui/src/lib.rs b/packages/dioxus-tui/src/lib.rs index 296fd4697..46ada5537 100644 --- a/packages/dioxus-tui/src/lib.rs +++ b/packages/dioxus-tui/src/lib.rs @@ -1,7 +1,9 @@ +mod element; pub mod prelude; pub mod widgets; use std::{ + any::Any, ops::Deref, rc::Rc, sync::{Arc, RwLock}, @@ -12,6 +14,7 @@ use dioxus_html::EventData; use dioxus_native_core::dioxus::{DioxusState, NodeImmutableDioxusExt}; use dioxus_native_core::prelude::*; +use element::{create_mounted_events, find_mount_events}; pub use rink::{query::Query, Config, RenderingMode, Size, TuiContext}; use rink::{render, Driver}; @@ -37,14 +40,32 @@ pub fn launch_cfg_with_props(app: Component, props: Props mapping: dioxus_state.clone(), }); let muts = vdom.rebuild(); - let mut rdom = rdom.write().unwrap(); - dioxus_state - .write() - .unwrap() - .apply_mutations(&mut rdom, muts); + + let mut queued_events = Vec::new(); + + { + let mut rdom = rdom.write().unwrap(); + let mut dioxus_state = dioxus_state.write().unwrap(); + + // Find any mount events + let mounted = dbg!(find_mount_events(&muts)); + + dioxus_state.apply_mutations(&mut rdom, muts); + + // Send the mount events + create_mounted_events( + &vdom, + &mut queued_events, + mounted + .iter() + .map(|id| (*dbg!(id), dioxus_state.element_to_node_id(*id))), + ); + } + DioxusRenderer { vdom, dioxus_state, + queued_events, #[cfg(all(feature = "hot-reload", debug_assertions))] hot_reload_rx: { let (hot_reload_tx, hot_reload_rx) = @@ -62,6 +83,8 @@ pub fn launch_cfg_with_props(app: Component, props: Props struct DioxusRenderer { vdom: VirtualDom, dioxus_state: Rc>, + // Events that are queued up to be sent to the vdom next time the vdom is polled + queued_events: Vec<(ElementId, &'static str, Rc, bool)>, #[cfg(all(feature = "hot-reload", debug_assertions))] hot_reload_rx: tokio::sync::mpsc::UnboundedReceiver, } @@ -71,10 +94,23 @@ impl Driver for DioxusRenderer { let muts = self.vdom.render_immediate(); { let mut rdom = rdom.write().unwrap(); - self.dioxus_state - .write() - .unwrap() - .apply_mutations(&mut rdom, muts); + + { + // Find any mount events + let mounted = find_mount_events(&muts); + + let mut dioxus_state = self.dioxus_state.write().unwrap(); + dioxus_state.apply_mutations(&mut rdom, muts); + + // Send the mount events + create_mounted_events( + &self.vdom, + &mut self.queued_events, + mounted + .iter() + .map(|id| (*id, dioxus_state.element_to_node_id(*id))), + ); + } } } @@ -94,6 +130,11 @@ impl Driver for DioxusRenderer { } fn poll_async(&mut self) -> std::pin::Pin + '_>> { + // Add any queued events + for (id, event, value, bubbles) in self.queued_events.drain(..) { + self.vdom.handle_event(event, value, id, bubbles); + } + #[cfg(all(feature = "hot-reload", debug_assertions))] return Box::pin(async { let hot_reload_wait = self.hot_reload_rx.recv(); diff --git a/packages/dioxus/Cargo.toml b/packages/dioxus/Cargo.toml index bc2098285..8f0cc2184 100644 --- a/packages/dioxus/Cargo.toml +++ b/packages/dioxus/Cargo.toml @@ -35,7 +35,7 @@ log = "0.4.14" rand = { version = "0.8.4", features = ["small_rng"] } criterion = "0.3.5" thiserror = "1.0.30" -env_logger = "0.9.0" +env_logger = "0.10.0" tokio = { version = "1.21.2", features = ["full"] } # dioxus-edit-stream = { path = "../edit-stream" } diff --git a/packages/fermi/src/hooks/atom_ref.rs b/packages/fermi/src/hooks/atom_ref.rs index fc7df59fc..95732c491 100644 --- a/packages/fermi/src/hooks/atom_ref.rs +++ b/packages/fermi/src/hooks/atom_ref.rs @@ -70,17 +70,46 @@ impl UseAtomRef { self.value.borrow() } + /// This is silent operation + /// call `.force_update()` manually if required + pub fn with_mut_silent(&self, cb: impl FnOnce(&mut T)) { + cb(&mut *self.write_silent()) + } + pub fn write(&self) -> RefMut { self.root.force_update(self.ptr); self.value.borrow_mut() } + /// Silent write to AtomRef + /// does not update Subscribed scopes pub fn write_silent(&self) -> RefMut { self.value.borrow_mut() } + /// Replace old value with new one pub fn set(&self, new: T) { self.root.force_update(self.ptr); self.root.set(self.ptr, new); } + + /// Do not update provided context on Write ops + /// Example: + /// ```ignore + /// static ATOM_DATA: AtomRef = |_| Default::default(); + /// fn App(cx: Scope) { + /// use_init_atom_root(cx); + /// let atom_data = use_atom_ref(cx, ATOM_DATA); + /// atom_data.unsubscribe(cx); + /// atom_data.write().update(); + /// } + /// ``` + pub fn unsubscribe(&self, cx: &ScopeState) { + self.root.unsubscribe(self.ptr, cx.scope_id()); + } + + /// Force update of subscribed Scopes + pub fn force_update(&self) { + self.root.force_update(self.ptr); + } } diff --git a/packages/fullstack/.gitignore b/packages/fullstack/.gitignore new file mode 100644 index 000000000..1de565933 --- /dev/null +++ b/packages/fullstack/.gitignore @@ -0,0 +1 @@ +target \ No newline at end of file diff --git a/packages/fullstack/Cargo.toml b/packages/fullstack/Cargo.toml new file mode 100644 index 000000000..5e7da25ca --- /dev/null +++ b/packages/fullstack/Cargo.toml @@ -0,0 +1,66 @@ +[package] +name = "dioxus-fullstack" +version = "0.1.0" +edition = "2021" +description = "Fullstack Dioxus Utilities" +license = "MIT/Apache-2.0" +repository = "https://github.com/DioxusLabs/dioxus/" +homepage = "https://dioxuslabs.com" +documentation = "https://dioxuslabs.com" +keywords = ["dom", "ui", "gui", "react", "ssr", "fullstack"] + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[dependencies] +# server functions +server_fn = { git = "https://github.com/leptos-rs/leptos", rev = "671b1e4a8fff7a2e05bb621ef08e87be2b18ccae", default-features = false, features = ["stable"] } +dioxus_server_macro = { path = "server-macro" } + +# warp +warp = { version = "0.3.3", optional = true } +http-body = { version = "0.4.5", optional = true } + +# axum +axum = { version = "0.6.1", features = ["ws"], optional = true } +tower-http = { version = "0.4.0", optional = true, features = ["fs"] } +axum-macros = "0.3.7" + +# salvo +salvo = { version = "0.37.7", optional = true, features = ["serve-static", "ws"] } +serde = "1.0.159" + +# Dioxus + SSR +dioxus-core = { path = "../core", version = "^0.3.0" } +dioxus-ssr = { path = "../ssr", version = "^0.3.0", optional = true } +hyper = { version = "0.14.25", optional = true } +http = { version = "0.2.9", optional = true } + +log = "0.4.17" +once_cell = "1.17.1" +thiserror = "1.0.40" +tokio = { version = "1.27.0", features = ["full"], optional = true } +object-pool = "0.5.4" +anymap = "0.12.1" + +serde_json = { version = "1.0.95", optional = true } +tokio-stream = { version = "0.1.12", features = ["sync"], optional = true } +futures-util = { version = "0.3.28", optional = true } +postcard = { version = "1.0.4", features = ["use-std"] } +yazi = "0.1.5" +base64 = "0.21.0" + +[target.'cfg(not(target_arch = "wasm32"))'.dependencies] +dioxus-hot-reload = { path = "../hot-reload" } + +[target.'cfg(target_arch = "wasm32")'.dependencies] +web-sys = { version = "0.3.61", features = ["Window", "Document", "Element", "HtmlDocument", "Storage", "console"] } + +[features] +default = ["hot-reload", "default-tls"] +hot-reload = ["serde_json", "tokio-stream", "futures-util"] +warp = ["dep:warp", "http-body", "ssr"] +axum = ["dep:axum", "tower-http", "ssr"] +salvo = ["dep:salvo", "ssr"] +ssr = ["server_fn/ssr", "tokio", "dioxus-ssr", "hyper", "http"] +default-tls = ["server_fn/default-tls"] +rustls = ["server_fn/rustls"] diff --git a/packages/fullstack/README.md b/packages/fullstack/README.md new file mode 100644 index 000000000..ec3d1c833 --- /dev/null +++ b/packages/fullstack/README.md @@ -0,0 +1,108 @@ +# Dioxus Fullstack + +[![Crates.io][crates-badge]][crates-url] +[![MIT licensed][mit-badge]][mit-url] +[![Build Status][actions-badge]][actions-url] +[![Discord chat][discord-badge]][discord-url] + +[crates-badge]: https://img.shields.io/crates/v/dioxus-fullstack.svg +[crates-url]: https://crates.io/crates/dioxus-fullstack +[mit-badge]: https://img.shields.io/badge/license-MIT-blue.svg +[mit-url]: https://github.com/dioxuslabs/dioxus/blob/master/LICENSE +[actions-badge]: https://github.com/dioxuslabs/dioxus/actions/workflows/main.yml/badge.svg +[actions-url]: https://github.com/dioxuslabs/dioxus/actions?query=workflow%3ACI+branch%3Amaster +[discord-badge]: https://img.shields.io/discord/899851952891002890.svg?logo=discord&style=flat-square +[discord-url]: https://discord.gg/XgGxMSkvUM + +[Website](https://dioxuslabs.com) | +[Guides](https://dioxuslabs.com/docs/0.3/guide/en/) | +[API Docs](https://docs.rs/dioxus-fullstack/latest/dioxus_sever) | +[Chat](https://discord.gg/XgGxMSkvUM) + +Fullstack utilities for the [`Dioxus`](https://dioxuslabs.com) framework. + +# Features + +- Intigrations with the [Axum](https::/docs.rs/dioxus-fullstack/latest/dixous_server/axum_adapter/index.html), [Salvo](https::/docs.rs/dioxus-fullstack/latest/dixous_server/salvo_adapter/index.html), and [Warp](https::/docs.rs/dioxus-fullstack/latest/dixous_server/warp_adapter/index.html) server frameworks with utilities for serving and rendering Dioxus applications. +- [Server functions](https::/docs.rs/dioxus-fullstack/latest/dixous_server/prelude/attr.server.html) allow you to call code on the server from the client as if it were a normal function. +- Instant RSX Hot reloading with [`dioxus-hot-reload`](https://crates.io/crates/dioxus-hot-reload). +- Passing root props from the server to the client. + +# Example + +Full stack Dioxus in under 50 lines of code + +```rust +#![allow(non_snake_case)] +use dioxus::prelude::*; +use dioxus_fullstack::prelude::*; + +fn main() { + #[cfg(feature = "web")] + dioxus_web::launch_with_props( + app, + get_root_props_from_document().unwrap_or_default(), + dioxus_web::Config::new().hydrate(true), + ); + #[cfg(feature = "ssr")] + { + GetMeaning::register().unwrap(); + tokio::runtime::Runtime::new() + .unwrap() + .block_on(async move { + warp::serve( + // Automatically handles server side rendering, hot reloading intigration, and hosting server functions + serve_dioxus_application( + "", + ServeConfigBuilder::new(app, ()), + ) + ) + .run(([127, 0, 0, 1], 8080)) + .await; + }); + } +} + +fn app(cx: Scope) -> Element { + let meaning = use_state(cx, || None); + cx.render(rsx! { + button { + onclick: move |_| { + to_owned![meaning]; + async move { + if let Ok(data) = get_meaning("life the universe and everything".into()).await { + meaning.set(data); + } + } + }, + "Run a server function" + } + "Server said: {meaning:?}" + }) +} + +// This code will only run on the server +#[server(GetMeaning)] +async fn get_meaning(of: String) -> Result, ServerFnError> { + Ok(of.contains("life").then(|| 42)) +} +``` + +## Getting Started + +To get started with full stack Dioxus, check out our [getting started guide](https://dioxuslabs.com/docs/nightly/guide/en/getting_started/ssr.html), or the [full stack examples](https://github.com/DioxusLabs/dioxus/tree/master/packages/fullstack/examples). + +## Contributing + +- Report issues on our [issue tracker](https://github.com/dioxuslabs/dioxus/issues). +- Join the discord and ask questions! + +## License + +This project is licensed under the [MIT license]. + +[mit license]: https://github.com/DioxusLabs/dioxus/blob/master/LICENSE-MIT + +Unless you explicitly state otherwise, any contribution intentionally submitted +for inclusion in Dioxus by you shall be licensed as MIT without any additional +terms or conditions. diff --git a/packages/fullstack/examples/axum-desktop/.gitignore b/packages/fullstack/examples/axum-desktop/.gitignore new file mode 100644 index 000000000..6047329c6 --- /dev/null +++ b/packages/fullstack/examples/axum-desktop/.gitignore @@ -0,0 +1,2 @@ +dist +target \ No newline at end of file diff --git a/packages/fullstack/examples/axum-desktop/Cargo.toml b/packages/fullstack/examples/axum-desktop/Cargo.toml new file mode 100644 index 000000000..c876e9d3d --- /dev/null +++ b/packages/fullstack/examples/axum-desktop/Cargo.toml @@ -0,0 +1,31 @@ +[package] +name = "axum-desktop" +version = "0.1.0" +edition = "2021" +publish = false + +[lib] + +[dependencies] +dioxus-desktop = { path = "../../../desktop", optional = true } +dioxus = { path = "../../../dioxus" } +dioxus-router = { path = "../../../router" } +dioxus-fullstack = { path = "../../" } +axum = { version = "0.6.12", optional = true } +tokio = { version = "1.27.0", features = ["full"], optional = true } +serde = "1.0.159" + +[features] +default = [] +ssr = ["axum", "tokio", "dioxus-fullstack/axum"] +desktop = ["dioxus-desktop"] + +[[bin]] +name = "client" +path = "src/client.rs" +required-features = ["desktop"] + +[[bin]] +name = "server" +path = "src/server.rs" +required-features = ["ssr"] diff --git a/packages/fullstack/examples/axum-desktop/src/client.rs b/packages/fullstack/examples/axum-desktop/src/client.rs new file mode 100644 index 000000000..8cb5b6cff --- /dev/null +++ b/packages/fullstack/examples/axum-desktop/src/client.rs @@ -0,0 +1,13 @@ +// Run with: +// ```bash +// cargo run --bin client --features="desktop" +// ``` + +use axum_desktop::*; +use dioxus_fullstack::prelude::server_fn::set_server_url; + +fn main() { + // Set the url of the server where server functions are hosted. + set_server_url("http://localhost:8080"); + dioxus_desktop::launch(app) +} diff --git a/packages/fullstack/examples/axum-desktop/src/lib.rs b/packages/fullstack/examples/axum-desktop/src/lib.rs new file mode 100644 index 000000000..1ac655ce1 --- /dev/null +++ b/packages/fullstack/examples/axum-desktop/src/lib.rs @@ -0,0 +1,40 @@ +#![allow(non_snake_case)] +use dioxus::prelude::*; +use dioxus_fullstack::prelude::*; + +pub fn app(cx: Scope) -> Element { + let mut count = use_state(cx, || 0); + let text = use_state(cx, || "...".to_string()); + + cx.render(rsx! { + h1 { "High-Five counter: {count}" } + button { onclick: move |_| count += 1, "Up high!" } + button { onclick: move |_| count -= 1, "Down low!" } + button { + onclick: move |_| { + to_owned![text]; + async move { + if let Ok(data) = get_server_data().await { + println!("Client received: {}", data); + text.set(data.clone()); + post_server_data(data).await.unwrap(); + } + } + }, + "Run a server function" + } + "Server said: {text}" + }) +} + +#[server(PostServerData)] +async fn post_server_data(data: String) -> Result<(), ServerFnError> { + println!("Server received: {}", data); + + Ok(()) +} + +#[server(GetServerData)] +async fn get_server_data() -> Result { + Ok("Hello from the server!".to_string()) +} diff --git a/packages/fullstack/examples/axum-desktop/src/server.rs b/packages/fullstack/examples/axum-desktop/src/server.rs new file mode 100644 index 000000000..7602d1e48 --- /dev/null +++ b/packages/fullstack/examples/axum-desktop/src/server.rs @@ -0,0 +1,23 @@ +// Run with: +// ```bash +// cargo run --bin server --features="ssr" +// ``` + +use axum_desktop::*; +use dioxus_fullstack::prelude::*; + +#[tokio::main] +async fn main() { + PostServerData::register().unwrap(); + GetServerData::register().unwrap(); + + let addr = std::net::SocketAddr::from(([127, 0, 0, 1], 8080)); + axum::Server::bind(&addr) + .serve( + axum::Router::new() + .register_server_fns("") + .into_make_service(), + ) + .await + .unwrap(); +} diff --git a/packages/fullstack/examples/axum-hello-world/.gitignore b/packages/fullstack/examples/axum-hello-world/.gitignore new file mode 100644 index 000000000..6047329c6 --- /dev/null +++ b/packages/fullstack/examples/axum-hello-world/.gitignore @@ -0,0 +1,2 @@ +dist +target \ No newline at end of file diff --git a/packages/fullstack/examples/axum-hello-world/Cargo.toml b/packages/fullstack/examples/axum-hello-world/Cargo.toml new file mode 100644 index 000000000..c1926b289 --- /dev/null +++ b/packages/fullstack/examples/axum-hello-world/Cargo.toml @@ -0,0 +1,21 @@ +[package] +name = "axum-hello-world" +version = "0.1.0" +edition = "2021" +publish = false + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[dependencies] +dioxus-web = { path = "../../../web", features=["hydrate"], optional = true } +dioxus = { path = "../../../dioxus" } +dioxus-fullstack = { path = "../../" } +axum = { version = "0.6.12", optional = true } +tokio = { version = "1.27.0", features = ["full"], optional = true } +serde = "1.0.159" +execute = "0.2.12" + +[features] +default = ["web"] +ssr = ["axum", "tokio", "dioxus-fullstack/axum"] +web = ["dioxus-web"] diff --git a/packages/fullstack/examples/axum-hello-world/src/main.rs b/packages/fullstack/examples/axum-hello-world/src/main.rs new file mode 100644 index 000000000..0301a190c --- /dev/null +++ b/packages/fullstack/examples/axum-hello-world/src/main.rs @@ -0,0 +1,101 @@ +//! Run with: +//! +//! ```sh +//! dioxus build --features web +//! cargo run --features ssr --no-default-features +//! ``` + +#![allow(non_snake_case)] +use dioxus::prelude::*; +use dioxus_fullstack::prelude::*; +use serde::{Deserialize, Serialize}; + +fn main() { + #[cfg(feature = "web")] + dioxus_web::launch_with_props( + app, + get_root_props_from_document().unwrap_or_default(), + dioxus_web::Config::new().hydrate(true), + ); + #[cfg(feature = "ssr")] + { + // Start hot reloading + hot_reload_init!(dioxus_hot_reload::Config::new().with_rebuild_callback(|| { + execute::shell("dioxus build --features web") + .spawn() + .unwrap() + .wait() + .unwrap(); + execute::shell("cargo run --features ssr --no-default-features") + .spawn() + .unwrap(); + true + })); + + PostServerData::register().unwrap(); + GetServerData::register().unwrap(); + tokio::runtime::Runtime::new() + .unwrap() + .block_on(async move { + let addr = std::net::SocketAddr::from(([127, 0, 0, 1], 8080)); + axum::Server::bind(&addr) + .serve( + axum::Router::new() + .serve_dioxus_application( + "", + ServeConfigBuilder::new(app, AppProps { count: 12345 }).build(), + ) + .into_make_service(), + ) + .await + .unwrap(); + }); + } +} + +#[derive(Props, PartialEq, Debug, Default, Serialize, Deserialize, Clone)] +struct AppProps { + count: i32, +} + +fn app(cx: Scope) -> Element { + let mut count = use_state(cx, || cx.props.count); + let text = use_state(cx, || "...".to_string()); + + cx.render(rsx! { + h1 { "High-Five counter: {count}" } + button { onclick: move |_| count += 1, "Up high!" } + button { onclick: move |_| count -= 1, "Down low!" } + button { + onclick: move |_| { + to_owned![text]; + let sc = cx.sc(); + async move { + if let Ok(data) = get_server_data().await { + println!("Client received: {}", data); + text.set(data.clone()); + post_server_data(sc, data).await.unwrap(); + } + } + }, + "Run a server function! testing1234" + } + "Server said: {text}" + }) +} + +#[server(PostServerData)] +async fn post_server_data(cx: DioxusServerContext, data: String) -> Result<(), ServerFnError> { + // The server context contains information about the current request and allows you to modify the response. + cx.response_headers_mut() + .insert("Set-Cookie", "foo=bar".parse().unwrap()); + println!("Server received: {}", data); + println!("Request parts are {:?}", cx.request_parts()); + + Ok(()) +} + +#[server(GetServerData)] +async fn get_server_data() -> Result { + Ok("Hello from the server!".to_string()) +} diff --git a/packages/fullstack/examples/axum-router/.gitignore b/packages/fullstack/examples/axum-router/.gitignore new file mode 100644 index 000000000..6047329c6 --- /dev/null +++ b/packages/fullstack/examples/axum-router/.gitignore @@ -0,0 +1,2 @@ +dist +target \ No newline at end of file diff --git a/packages/fullstack/examples/axum-router/Cargo.toml b/packages/fullstack/examples/axum-router/Cargo.toml new file mode 100644 index 000000000..db4138430 --- /dev/null +++ b/packages/fullstack/examples/axum-router/Cargo.toml @@ -0,0 +1,24 @@ +[package] +name = "axum-router" +version = "0.1.0" +edition = "2021" +publish = false + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[dependencies] +dioxus-web = { path = "../../../web", features=["hydrate"], optional = true } +dioxus = { path = "../../../dioxus" } +dioxus-router = { path = "../../../router" } +dioxus-fullstack = { path = "../../" } +axum = { version = "0.6.12", optional = true } +tokio = { version = "1.27.0", features = ["full"], optional = true } +serde = "1.0.159" +tower-http = { version = "0.4.0", features = ["fs"], optional = true } +http = { version = "0.2.9", optional = true } +execute = "0.2.12" + +[features] +default = ["web"] +ssr = ["axum", "tokio", "dioxus-fullstack/axum", "tower-http", "http"] +web = ["dioxus-web", "dioxus-router/web"] diff --git a/packages/fullstack/examples/axum-router/src/main.rs b/packages/fullstack/examples/axum-router/src/main.rs new file mode 100644 index 000000000..96567c791 --- /dev/null +++ b/packages/fullstack/examples/axum-router/src/main.rs @@ -0,0 +1,153 @@ +//! Run with: +//! +//! ```sh +//! dioxus build --features web +//! cargo run --features ssr --no-default-features +//! ``` + +#![allow(non_snake_case)] +use dioxus::prelude::*; +use dioxus_fullstack::prelude::*; +use dioxus_router::*; +use serde::{Deserialize, Serialize}; + +fn main() { + #[cfg(feature = "web")] + dioxus_web::launch_with_props( + App, + AppProps { route: None }, + dioxus_web::Config::new().hydrate(true), + ); + #[cfg(feature = "ssr")] + { + // Start hot reloading + hot_reload_init!(dioxus_hot_reload::Config::new().with_rebuild_callback(|| { + execute::shell("dioxus build --features web") + .spawn() + .unwrap() + .wait() + .unwrap(); + execute::shell("cargo run --features ssr --no-default-features") + .spawn() + .unwrap(); + true + })); + + use axum::extract::State; + PostServerData::register().unwrap(); + GetServerData::register().unwrap(); + tokio::runtime::Runtime::new() + .unwrap() + .block_on(async move { + let addr = std::net::SocketAddr::from(([127, 0, 0, 1], 8080)); + + axum::Server::bind(&addr) + .serve( + axum::Router::new() + // Serve the dist/assets folder with the javascript and WASM files created by the CLI + .serve_static_assets("./dist") + // Register server functions + .register_server_fns("") + // Connect to the hot reload server + .connect_hot_reload() + // If the path is unknown, render the application + .fallback( + move |uri: http::uri::Uri, State(ssr_state): State| { + let rendered = ssr_state.render( + &ServeConfigBuilder::new( + App, + AppProps { + route: Some(format!("http://{addr}{uri}")), + }, + ) + .build(), + ); + async move { axum::body::Full::from(rendered) } + }, + ) + .with_state(SSRState::default()) + .into_make_service(), + ) + .await + .unwrap(); + }); + } +} + +#[derive(Clone, Debug, Props, PartialEq, Serialize, Deserialize)] +struct AppProps { + route: Option, +} + +fn App(cx: Scope) -> Element { + cx.render(rsx! { + Router { + initial_url: cx.props.route.clone(), + + Route { to: "/blog", + Link { + to: "/", + "Go to counter" + } + table { + tbody { + for _ in 0..100 { + tr { + for _ in 0..100 { + td { "hello world!" } + } + } + } + } + } + }, + // Fallback + Route { to: "", + Counter {} + }, + } + }) +} + +fn Counter(cx: Scope) -> Element { + let mut count = use_state(cx, || 0); + let text = use_state(cx, || "...".to_string()); + + cx.render(rsx! { + Link { + to: "/blog", + "Go to blog" + } + div{ + h1 { "High-Five counter: {count}" } + button { onclick: move |_| count += 1, "Up high!" } + button { onclick: move |_| count -= 1, "Down low!" } + button { + onclick: move |_| { + to_owned![text]; + async move { + if let Ok(data) = get_server_data().await { + println!("Client received: {}", data); + text.set(data.clone()); + post_server_data(data).await.unwrap(); + } + } + }, + "Run a server function" + } + "Server said: {text}" + } + }) +} + +#[server(PostServerData)] +async fn post_server_data(data: String) -> Result<(), ServerFnError> { + println!("Server received: {}", data); + + Ok(()) +} + +#[server(GetServerData)] +async fn get_server_data() -> Result { + Ok("Hello from the server!".to_string()) +} diff --git a/packages/fullstack/examples/salvo-hello-world/.gitignore b/packages/fullstack/examples/salvo-hello-world/.gitignore new file mode 100644 index 000000000..6047329c6 --- /dev/null +++ b/packages/fullstack/examples/salvo-hello-world/.gitignore @@ -0,0 +1,2 @@ +dist +target \ No newline at end of file diff --git a/packages/fullstack/examples/salvo-hello-world/Cargo.toml b/packages/fullstack/examples/salvo-hello-world/Cargo.toml new file mode 100644 index 000000000..27b7e73dc --- /dev/null +++ b/packages/fullstack/examples/salvo-hello-world/Cargo.toml @@ -0,0 +1,21 @@ +[package] +name = "salvo-hello-world" +version = "0.1.0" +edition = "2021" +publish = false + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[dependencies] +dioxus-web = { path = "../../../web", features=["hydrate"], optional = true } +dioxus = { path = "../../../dioxus" } +dioxus-fullstack = { path = "../../" } +tokio = { version = "1.27.0", features = ["full"], optional = true } +serde = "1.0.159" +salvo = { version = "0.37.9", optional = true } +execute = "0.2.12" + +[features] +default = ["web"] +ssr = ["salvo", "tokio", "dioxus-fullstack/salvo"] +web = ["dioxus-web"] diff --git a/packages/fullstack/examples/salvo-hello-world/src/main.rs b/packages/fullstack/examples/salvo-hello-world/src/main.rs new file mode 100644 index 000000000..0cbd3598f --- /dev/null +++ b/packages/fullstack/examples/salvo-hello-world/src/main.rs @@ -0,0 +1,97 @@ +//! Run with: +//! +//! ```sh +//! dioxus build --features web +//! cargo run --features ssr --no-default-features +//! ``` + +#![allow(non_snake_case)] +use dioxus::prelude::*; +use dioxus_fullstack::prelude::*; +use serde::{Deserialize, Serialize}; + +fn main() { + #[cfg(feature = "web")] + dioxus_web::launch_with_props( + app, + get_root_props_from_document().unwrap_or_default(), + dioxus_web::Config::new().hydrate(true), + ); + #[cfg(feature = "ssr")] + { + // Start hot reloading + hot_reload_init!(dioxus_hot_reload::Config::new().with_rebuild_callback(|| { + execute::shell("dioxus build --features web") + .spawn() + .unwrap() + .wait() + .unwrap(); + execute::shell("cargo run --features ssr --no-default-features") + .spawn() + .unwrap(); + true + })); + + use salvo::prelude::*; + PostServerData::register().unwrap(); + GetServerData::register().unwrap(); + tokio::runtime::Runtime::new() + .unwrap() + .block_on(async move { + let router = Router::new().serve_dioxus_application( + "", + ServeConfigBuilder::new(app, AppProps { count: 12345 }), + ); + Server::new(TcpListener::bind("127.0.0.1:8080")) + .serve(router) + .await; + }); + } +} + +#[derive(Props, PartialEq, Debug, Default, Serialize, Deserialize, Clone)] +struct AppProps { + count: i32, +} + +fn app(cx: Scope) -> Element { + let mut count = use_state(cx, || cx.props.count); + let text = use_state(cx, || "...".to_string()); + let server_context = cx.sc(); + + cx.render(rsx! { + h1 { "High-Five counter: {count}" } + button { onclick: move |_| count += 1, "Up high!" } + button { onclick: move |_| count -= 1, "Down low!" } + button { + onclick: move |_| { + to_owned![text, server_context]; + async move { + if let Ok(data) = get_server_data().await { + println!("Client received: {}", data); + text.set(data.clone()); + post_server_data(server_context, data).await.unwrap(); + } + } + }, + "Run a server function" + } + "Server said: {text}" + }) +} + +#[server(PostServerData)] +async fn post_server_data(cx: DioxusServerContext, data: String) -> Result<(), ServerFnError> { + // The server context contains information about the current request and allows you to modify the response. + cx.response_headers_mut() + .insert("Set-Cookie", "foo=bar".parse().unwrap()); + println!("Server received: {}", data); + println!("Request parts are {:?}", cx.request_parts()); + + Ok(()) +} + +#[server(GetServerData)] +async fn get_server_data() -> Result { + Ok("Hello from the server!".to_string()) +} diff --git a/packages/fullstack/examples/warp-hello-world/.gitignore b/packages/fullstack/examples/warp-hello-world/.gitignore new file mode 100644 index 000000000..6047329c6 --- /dev/null +++ b/packages/fullstack/examples/warp-hello-world/.gitignore @@ -0,0 +1,2 @@ +dist +target \ No newline at end of file diff --git a/packages/fullstack/examples/warp-hello-world/Cargo.toml b/packages/fullstack/examples/warp-hello-world/Cargo.toml new file mode 100644 index 000000000..18da0b249 --- /dev/null +++ b/packages/fullstack/examples/warp-hello-world/Cargo.toml @@ -0,0 +1,21 @@ +[package] +name = "warp-hello-world" +version = "0.1.0" +edition = "2021" +publish = false + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[dependencies] +dioxus-web = { path = "../../../web", features=["hydrate"], optional = true } +dioxus = { path = "../../../dioxus" } +dioxus-fullstack = { path = "../../" } +tokio = { version = "1.27.0", features = ["full"], optional = true } +serde = "1.0.159" +warp = { version = "0.3.3", optional = true } +execute = "0.2.12" + +[features] +default = ["web"] +ssr = ["warp", "tokio", "dioxus-fullstack/warp"] +web = ["dioxus-web"] diff --git a/packages/fullstack/examples/warp-hello-world/src/main.rs b/packages/fullstack/examples/warp-hello-world/src/main.rs new file mode 100644 index 000000000..5e4c9aecb --- /dev/null +++ b/packages/fullstack/examples/warp-hello-world/src/main.rs @@ -0,0 +1,94 @@ +//! Run with: +//! +//! ```sh +//! dioxus build --features web +//! cargo run --features ssr --no-default-features +//! ``` + +#![allow(non_snake_case)] +use dioxus::prelude::*; +use dioxus_fullstack::prelude::*; +use serde::{Deserialize, Serialize}; + +fn main() { + #[cfg(feature = "web")] + dioxus_web::launch_with_props( + app, + get_root_props_from_document().unwrap_or_default(), + dioxus_web::Config::new().hydrate(true), + ); + #[cfg(feature = "ssr")] + { + // Start hot reloading + hot_reload_init!(dioxus_hot_reload::Config::new().with_rebuild_callback(|| { + execute::shell("dioxus build --features web") + .spawn() + .unwrap() + .wait() + .unwrap(); + execute::shell("cargo run --features ssr --no-default-features") + .spawn() + .unwrap(); + true + })); + + PostServerData::register().unwrap(); + GetServerData::register().unwrap(); + tokio::runtime::Runtime::new() + .unwrap() + .block_on(async move { + let routes = serve_dioxus_application( + "", + ServeConfigBuilder::new(app, AppProps { count: 12345 }), + ); + warp::serve(routes).run(([127, 0, 0, 1], 8080)).await; + }); + } +} + +#[derive(Props, PartialEq, Debug, Default, Serialize, Deserialize, Clone)] +struct AppProps { + count: i32, +} + +fn app(cx: Scope) -> Element { + let mut count = use_state(cx, || cx.props.count); + let text = use_state(cx, || "...".to_string()); + let server_context = cx.sc(); + + cx.render(rsx! { + h1 { "High-Five counter: {count}" } + button { onclick: move |_| count += 10, "Up high!" } + button { onclick: move |_| count -= 1, "Down low!" } + button { + onclick: move |_| { + to_owned![text, server_context]; + async move { + if let Ok(data) = get_server_data().await { + println!("Client received: {}", data); + text.set(data.clone()); + post_server_data(server_context, data).await.unwrap(); + } + } + }, + "Run a server function" + } + "Server said: {text}" + }) +} + +#[server(PostServerData)] +async fn post_server_data(cx: DioxusServerContext, data: String) -> Result<(), ServerFnError> { + // The server context contains information about the current request and allows you to modify the response. + cx.response_headers_mut() + .insert("Set-Cookie", "foo=bar".parse().unwrap()); + println!("Server received: {}", data); + println!("Request parts are {:?}", cx.request_parts()); + + Ok(()) +} + +#[server(GetServerData)] +async fn get_server_data() -> Result { + Ok("Hello from the server!".to_string()) +} diff --git a/packages/fullstack/server-macro/Cargo.toml b/packages/fullstack/server-macro/Cargo.toml new file mode 100644 index 000000000..286481bdd --- /dev/null +++ b/packages/fullstack/server-macro/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "dioxus_server_macro" +version = "0.1.0" +edition = "2021" + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[dependencies] +quote = "1.0.26" +server_fn_macro = { git = "https://github.com/leptos-rs/leptos", rev = "671b1e4a8fff7a2e05bb621ef08e87be2b18ccae", features = ["stable"] } +syn = { version = "1", features = ["full"] } + +[lib] +proc-macro = true diff --git a/packages/fullstack/server-macro/src/lib.rs b/packages/fullstack/server-macro/src/lib.rs new file mode 100644 index 000000000..f6df526c1 --- /dev/null +++ b/packages/fullstack/server-macro/src/lib.rs @@ -0,0 +1,71 @@ +use proc_macro::TokenStream; +use quote::ToTokens; +use server_fn_macro::*; + +/// Declares that a function is a [server function](dioxus_fullstack). This means that +/// its body will only run on the server, i.e., when the `ssr` feature is enabled. +/// +/// If you call a server function from the client (i.e., when the `csr` or `hydrate` features +/// are enabled), it will instead make a network request to the server. +/// +/// You can specify one, two, or three arguments to the server function: +/// 1. **Required**: A type name that will be used to identify and register the server function +/// (e.g., `MyServerFn`). +/// 2. *Optional*: A URL prefix at which the function will be mounted when it’s registered +/// (e.g., `"/api"`). Defaults to `"/"`. +/// 3. *Optional*: either `"Cbor"` (specifying that it should use the binary `cbor` format for +/// serialization), `"Url"` (specifying that it should be use a URL-encoded form-data string). +/// Defaults to `"Url"`. If you want to use this server function +/// using Get instead of Post methods, the encoding must be `"GetCbor"` or `"GetJson"`. +/// +/// The server function itself can take any number of arguments, each of which should be serializable +/// and deserializable with `serde`. Optionally, its first argument can be a [DioxusServerContext](https::/docs.rs/dioxus-fullstack/latest/dixous_server/prelude/struct.DioxusServerContext.html), +/// which will be injected *on the server side.* This can be used to inject the raw HTTP request or other +/// server-side context into the server function. +/// +/// ```ignore +/// # use dioxus_fullstack::prelude::*; use serde::{Serialize, Deserialize}; +/// # #[derive(Serialize, Deserialize)] +/// # pub struct Post { } +/// #[server(ReadPosts, "/api")] +/// pub async fn read_posts(how_many: u8, query: String) -> Result, ServerFnError> { +/// // do some work on the server to access the database +/// todo!() +/// } +/// ``` +/// +/// Note the following: +/// - You must **register** the server function by calling `T::register()` somewhere in your main function. +/// - **Server functions must be `async`.** Even if the work being done inside the function body +/// can run synchronously on the server, from the client’s perspective it involves an asynchronous +/// function call. +/// - **Server functions must return `Result`.** Even if the work being done +/// inside the function body can’t fail, the processes of serialization/deserialization and the +/// network call are fallible. +/// - **Return types must implement [`Serialize`](https://docs.rs/serde/latest/serde/trait.Serialize.html).** +/// This should be fairly obvious: we have to serialize arguments to send them to the server, and we +/// need to deserialize the result to return it to the client. +/// - **Arguments must be implement [`Serialize`](https://docs.rs/serde/latest/serde/trait.Serialize.html) +/// and [`DeserializeOwned`](https://docs.rs/serde/latest/serde/de/trait.DeserializeOwned.html).** +/// They are serialized as an `application/x-www-form-urlencoded` +/// form data using [`serde_urlencoded`](https://docs.rs/serde_urlencoded/latest/serde_urlencoded/) or as `application/cbor` +/// using [`cbor`](https://docs.rs/cbor/latest/cbor/). +/// - **The [DioxusServerContext](https::/docs.rs/dioxus-fullstack/latest/dixous_server/prelude/struct.DioxusServerContext.html) comes from the server.** Optionally, the first argument of a server function +/// can be a [DioxusServerContext](https::/docs.rs/dioxus-fullstack/latest/dixous_server/prelude/struct.DioxusServerContext.html). This scope can be used to inject dependencies like the HTTP request +/// or response or other server-only dependencies, but it does *not* have access to reactive state that exists in the client. +#[proc_macro_attribute] +pub fn server(args: proc_macro::TokenStream, s: TokenStream) -> TokenStream { + let context = ServerContext { + ty: syn::parse_quote!(DioxusServerContext), + path: syn::parse_quote!(::dioxus_fullstack::prelude::DioxusServerContext), + }; + match server_macro_impl( + args.into(), + s.into(), + Some(context), + Some(syn::parse_quote!(::dioxus_fullstack::prelude::server_fn)), + ) { + Err(e) => e.to_compile_error().into(), + Ok(s) => s.to_token_stream().into(), + } +} diff --git a/packages/fullstack/src/adapters/axum_adapter.rs b/packages/fullstack/src/adapters/axum_adapter.rs new file mode 100644 index 000000000..c634d7f77 --- /dev/null +++ b/packages/fullstack/src/adapters/axum_adapter.rs @@ -0,0 +1,492 @@ +//! Dioxus utilities for the [Axum](https://docs.rs/axum/latest/axum/index.html) server framework. +//! +//! # Example +//! ```rust +//! #![allow(non_snake_case)] +//! use dioxus::prelude::*; +//! use dioxus_fullstack::prelude::*; +//! +//! fn main() { +//! #[cfg(feature = "web")] +//! // Hydrate the application on the client +//! dioxus_web::launch_cfg(app, dioxus_web::Config::new().hydrate(true)); +//! #[cfg(feature = "ssr")] +//! { +//! GetServerData::register().unwrap(); +//! tokio::runtime::Runtime::new() +//! .unwrap() +//! .block_on(async move { +//! let addr = std::net::SocketAddr::from(([127, 0, 0, 1], 8080)); +//! axum::Server::bind(&addr) +//! .serve( +//! axum::Router::new() +//! // Server side render the application, serve static assets, and register server functions +//! .serve_dioxus_application("", ServeConfigBuilder::new(app, ())) +//! .into_make_service(), +//! ) +//! .await +//! .unwrap(); +//! }); +//! } +//! } +//! +//! fn app(cx: Scope) -> Element { +//! let text = use_state(cx, || "...".to_string()); +//! +//! cx.render(rsx! { +//! button { +//! onclick: move |_| { +//! to_owned![text]; +//! async move { +//! if let Ok(data) = get_server_data().await { +//! text.set(data); +//! } +//! } +//! }, +//! "Run a server function" +//! } +//! "Server said: {text}" +//! }) +//! } +//! +//! #[server(GetServerData)] +//! async fn get_server_data() -> Result { +//! Ok("Hello from the server!".to_string()) +//! } +//! ``` + +use axum::{ + body::{self, Body, BoxBody, Full}, + extract::{State, WebSocketUpgrade}, + handler::Handler, + http::{Request, Response, StatusCode}, + response::IntoResponse, + routing::{get, post}, + Router, +}; +use dioxus_core::VirtualDom; +use server_fn::{Encoding, Payload, ServerFunctionRegistry}; +use std::error::Error; +use std::sync::Arc; +use tokio::task::spawn_blocking; + +use crate::{ + prelude::*, render::SSRState, serve_config::ServeConfig, server_context::DioxusServerContext, + server_fn::DioxusServerFnRegistry, +}; + +/// A extension trait with utilities for integrating Dioxus with your Axum router. +pub trait DioxusRouterExt { + /// Registers server functions with a custom handler function. This allows you to pass custom context to your server functions by generating a [`DioxusServerContext`] from the request. + /// + /// # Example + /// ```rust + /// use dioxus::prelude::*; + /// use dioxus_fullstack::prelude::*; + /// + /// #[tokio::main] + /// async fn main() { + /// let addr = std::net::SocketAddr::from(([127, 0, 0, 1], 8080)); + /// axum::Server::bind(&addr) + /// .serve( + /// axum::Router::new() + /// .register_server_fns_with_handler("", |func| { + /// move |req: Request| async move { + /// let (parts, body) = req.into_parts(); + /// let parts: Arc = Arc::new(parts.into()); + /// let server_context = DioxusServerContext::new(parts.clone()); + /// server_fn_handler(server_context, func.clone(), parts, body).await + /// } + /// }) + /// .into_make_service(), + /// ) + /// .await + /// .unwrap(); + /// } + /// ``` + fn register_server_fns_with_handler( + self, + server_fn_route: &'static str, + handler: impl Fn(ServerFunction) -> H, + ) -> Self + where + H: Handler, + T: 'static, + S: Clone + Send + Sync + 'static; + + /// Registers server functions with the default handler. This handler function will pass an empty [`DioxusServerContext`] to your server functions. + /// + /// # Example + /// ```rust + /// use dioxus::prelude::*; + /// use dioxus_fullstack::prelude::*; + /// + /// #[tokio::main] + /// async fn main() { + /// let addr = std::net::SocketAddr::from(([127, 0, 0, 1], 8080)); + /// axum::Server::bind(&addr) + /// .serve( + /// axum::Router::new() + /// // Register server functions routes with the default handler + /// .register_server_fns("") + /// .into_make_service(), + /// ) + /// .await + /// .unwrap(); + /// } + /// ``` + fn register_server_fns(self, server_fn_route: &'static str) -> Self; + + /// Register the web RSX hot reloading endpoint. This will enable hot reloading for your application in debug mode when you call [`dioxus_hot_reload::hot_reload_init`]. + /// + /// # Example + /// ```rust + /// #![allow(non_snake_case)] + /// use dioxus_fullstack::prelude::*; + /// + /// #[tokio::main] + /// async fn main() { + /// hot_reload_init!(); + /// let addr = std::net::SocketAddr::from(([127, 0, 0, 1], 8080)); + /// axum::Server::bind(&addr) + /// .serve( + /// axum::Router::new() + /// // Connect to hot reloading in debug mode + /// .connect_hot_reload() + /// .into_make_service(), + /// ) + /// .await + /// .unwrap(); + /// } + /// ``` + fn connect_hot_reload(self) -> Self; + + /// Serves the static WASM for your Dioxus application (except the generated index.html). + /// + /// # Example + /// ```rust + /// #![allow(non_snake_case)] + /// use dioxus::prelude::*; + /// use dioxus_fullstack::prelude::*; + /// + /// #[tokio::main] + /// async fn main() { + /// let addr = std::net::SocketAddr::from(([127, 0, 0, 1], 8080)); + /// axum::Server::bind(&addr) + /// .serve( + /// axum::Router::new() + /// // Server side render the application, serve static assets, and register server functions + /// .serve_static_assets(ServeConfigBuilder::new(app, ())) + /// // Server render the application + /// // ... + /// .into_make_service(), + /// ) + /// .await + /// .unwrap(); + /// } + /// + /// fn app(cx: Scope) -> Element { + /// todo!() + /// } + /// ``` + fn serve_static_assets(self, assets_path: impl Into) -> Self; + + /// Serves the Dioxus application. This will serve a complete server side rendered application. + /// This will serve static assets, server render the application, register server functions, and intigrate with hot reloading. + /// + /// # Example + /// ```rust + /// #![allow(non_snake_case)] + /// use dioxus::prelude::*; + /// use dioxus_fullstack::prelude::*; + /// + /// #[tokio::main] + /// async fn main() { + /// let addr = std::net::SocketAddr::from(([127, 0, 0, 1], 8080)); + /// axum::Server::bind(&addr) + /// .serve( + /// axum::Router::new() + /// // Server side render the application, serve static assets, and register server functions + /// .serve_dioxus_application("", ServeConfigBuilder::new(app, ())) + /// .into_make_service(), + /// ) + /// .await + /// .unwrap(); + /// } + /// + /// fn app(cx: Scope) -> Element { + /// todo!() + /// } + /// ``` + fn serve_dioxus_application( + self, + server_fn_route: &'static str, + cfg: impl Into>, + ) -> Self; +} + +impl DioxusRouterExt for Router +where + S: Send + Sync + Clone + 'static, +{ + fn register_server_fns_with_handler( + self, + server_fn_route: &'static str, + mut handler: impl FnMut(ServerFunction) -> H, + ) -> Self + where + H: Handler, + T: 'static, + S: Clone + Send + Sync + 'static, + { + let mut router = self; + for server_fn_path in DioxusServerFnRegistry::paths_registered() { + let func = DioxusServerFnRegistry::get(server_fn_path).unwrap(); + let full_route = format!("{server_fn_route}/{server_fn_path}"); + match func.encoding { + Encoding::Url | Encoding::Cbor => { + router = router.route(&full_route, post(handler(func))); + } + Encoding::GetJSON | Encoding::GetCBOR => { + router = router.route(&full_route, get(handler(func))); + } + } + } + router + } + + fn register_server_fns(self, server_fn_route: &'static str) -> Self { + self.register_server_fns_with_handler(server_fn_route, |func| { + move |req: Request| async move { + let (parts, body) = req.into_parts(); + let parts: Arc = Arc::new(parts.into()); + let server_context = DioxusServerContext::new(parts.clone()); + server_fn_handler(server_context, func.clone(), parts, body).await + } + }) + } + + fn serve_static_assets(mut self, assets_path: impl Into) -> Self { + use tower_http::services::{ServeDir, ServeFile}; + + let assets_path = assets_path.into(); + + // Serve all files in dist folder except index.html + let dir = std::fs::read_dir(&assets_path).unwrap_or_else(|e| { + panic!( + "Couldn't read assets directory at {:?}: {}", + &assets_path, e + ) + }); + + for entry in dir.flatten() { + let path = entry.path(); + if path.ends_with("index.html") { + continue; + } + let route = path + .strip_prefix(&assets_path) + .unwrap() + .iter() + .map(|segment| { + segment.to_str().unwrap_or_else(|| { + panic!("Failed to convert path segment {:?} to string", segment) + }) + }) + .collect::>() + .join("/"); + let route = format!("/{}", route); + if path.is_dir() { + self = self.nest_service(&route, ServeDir::new(path)); + } else { + self = self.nest_service(&route, ServeFile::new(path)); + } + } + + self + } + + fn serve_dioxus_application( + self, + server_fn_route: &'static str, + cfg: impl Into>, + ) -> Self { + let cfg = cfg.into(); + + // Add server functions and render index.html + self.serve_static_assets(&cfg.assets_path) + .route( + "/", + get(render_handler).with_state((cfg, SSRState::default())), + ) + .connect_hot_reload() + .register_server_fns(server_fn_route) + } + + fn connect_hot_reload(self) -> Self { + #[cfg(all(debug_assertions, feature = "hot-reload", feature = "ssr"))] + { + self.nest( + "/_dioxus", + Router::new() + .route( + "/disconnect", + get(|ws: WebSocketUpgrade| async { + ws.on_upgrade(|mut ws| async move { + use axum::extract::ws::Message; + let _ = ws.send(Message::Text("connected".into())).await; + loop { + if ws.recv().await.is_none() { + break; + } + } + }) + }), + ) + .route("/hot_reload", get(hot_reload_handler)), + ) + } + #[cfg(not(all(debug_assertions, feature = "hot-reload", feature = "ssr")))] + { + self + } + } +} + +async fn render_handler( + State((cfg, ssr_state)): State<(ServeConfig

, SSRState)>, + request: Request, +) -> impl IntoResponse { + let (parts, _) = request.into_parts(); + let parts: Arc = Arc::new(parts.into()); + let server_context = DioxusServerContext::new(parts); + let mut vdom = + VirtualDom::new_with_props(cfg.app, cfg.props.clone()).with_root_context(server_context); + let _ = vdom.rebuild(); + + let rendered = ssr_state.render_vdom(&vdom, &cfg); + Full::from(rendered) +} + +/// A default handler for server functions. It will deserialize the request, call the server function, and serialize the response. +pub async fn server_fn_handler( + server_context: DioxusServerContext, + function: ServerFunction, + parts: Arc, + body: Body, +) -> impl IntoResponse { + let body = hyper::body::to_bytes(body).await; + let Ok(body) = body else { + return report_err(body.err().unwrap()); + }; + + // Because the future returned by `server_fn_handler` is `Send`, and the future returned by this function must be send, we need to spawn a new runtime + let (resp_tx, resp_rx) = tokio::sync::oneshot::channel(); + let query_string = parts.uri.query().unwrap_or_default().to_string(); + spawn_blocking({ + move || { + tokio::runtime::Runtime::new() + .expect("couldn't spawn runtime") + .block_on(async { + let query = &query_string.into(); + let data = match &function.encoding { + Encoding::Url | Encoding::Cbor => &body, + Encoding::GetJSON | Encoding::GetCBOR => query, + }; + let resp = match (function.trait_obj)(server_context.clone(), &data).await { + Ok(serialized) => { + // if this is Accept: application/json then send a serialized JSON response + let accept_header = parts + .headers + .get("Accept") + .and_then(|value| value.to_str().ok()); + let mut res = Response::builder(); + *res.headers_mut().expect("empty response should be valid") = + server_context.take_response_headers(); + if accept_header == Some("application/json") + || accept_header + == Some( + "application/\ + x-www-form-urlencoded", + ) + || accept_header == Some("application/cbor") + { + res = res.status(StatusCode::OK); + } + + let resp = match serialized { + Payload::Binary(data) => res + .header("Content-Type", "application/cbor") + .body(body::boxed(Full::from(data))), + Payload::Url(data) => res + .header( + "Content-Type", + "application/\ + x-www-form-urlencoded", + ) + .body(body::boxed(data)), + Payload::Json(data) => res + .header("Content-Type", "application/json") + .body(body::boxed(data)), + }; + + resp.unwrap() + } + Err(e) => report_err(e), + }; + + resp_tx.send(resp).unwrap(); + }) + } + }); + resp_rx.await.unwrap() +} + +fn report_err(e: E) -> Response { + Response::builder() + .status(StatusCode::INTERNAL_SERVER_ERROR) + .body(body::boxed(format!("Error: {}", e))) + .unwrap() +} + +/// A handler for Dioxus web hot reload websocket. This will send the updated static parts of the RSX to the client when they change. +#[cfg(all(debug_assertions, feature = "hot-reload", feature = "ssr"))] +pub async fn hot_reload_handler(ws: WebSocketUpgrade) -> impl IntoResponse { + use axum::extract::ws::Message; + use futures_util::StreamExt; + + let state = crate::hot_reload::spawn_hot_reload().await; + + ws.on_upgrade(move |mut socket| async move { + println!("🔥 Hot Reload WebSocket connected"); + { + // update any rsx calls that changed before the websocket connected. + { + println!("🔮 Finding updates since last compile..."); + let templates_read = state.templates.read().await; + + for template in &*templates_read { + if socket + .send(Message::Text(serde_json::to_string(&template).unwrap())) + .await + .is_err() + { + return; + } + } + } + println!("finished"); + } + + let mut rx = + tokio_stream::wrappers::WatchStream::from_changes(state.message_receiver.clone()); + while let Some(change) = rx.next().await { + if let Some(template) = change { + let template = { serde_json::to_string(&template).unwrap() }; + if socket.send(Message::Text(template)).await.is_err() { + break; + }; + } + } + }) +} diff --git a/packages/fullstack/src/adapters/mod.rs b/packages/fullstack/src/adapters/mod.rs new file mode 100644 index 000000000..5809132b8 --- /dev/null +++ b/packages/fullstack/src/adapters/mod.rs @@ -0,0 +1,18 @@ +//! # Adapters +//! Adapters for different web frameworks. +//! +//! Each adapter provides a set of utilities that is ergonomic to use with the framework. +//! +//! Each framework has utilies for some or all of the following: +//! - Server functions +//! - A generic way to register server functions +//! - A way to register server functions with a custom handler that allows users to pass in a custom [`crate::server_context::DioxusServerContext`] based on the state of the server framework. +//! - A way to register static WASM files that is accepts [`crate::serve_config::ServeConfig`] +//! - A hot reloading web socket that intigrates with [`dioxus-hot-reload`](https://crates.io/crates/dioxus-hot-reload) + +#[cfg(feature = "axum")] +pub mod axum_adapter; +#[cfg(feature = "salvo")] +pub mod salvo_adapter; +#[cfg(feature = "warp")] +pub mod warp_adapter; diff --git a/packages/fullstack/src/adapters/salvo_adapter.rs b/packages/fullstack/src/adapters/salvo_adapter.rs new file mode 100644 index 000000000..94814fea0 --- /dev/null +++ b/packages/fullstack/src/adapters/salvo_adapter.rs @@ -0,0 +1,554 @@ +//! Dioxus utilities for the [Salvo](https://salvo.rs) server framework. +//! +//! # Example +//! ```rust +//! #![allow(non_snake_case)] +//! use dioxus::prelude::*; +//! use dioxus_fullstack::prelude::*; +//! +//! fn main() { +//! #[cfg(feature = "web")] +//! dioxus_web::launch_cfg(app, dioxus_web::Config::new().hydrate(true)); +//! #[cfg(feature = "ssr")] +//! { +//! use salvo::prelude::*; +//! GetServerData::register().unwrap(); +//! tokio::runtime::Runtime::new() +//! .unwrap() +//! .block_on(async move { +//! let router = +//! Router::new().serve_dioxus_application("", ServeConfigBuilder::new(app, ())); +//! Server::new(TcpListener::bind("127.0.0.1:8080")) +//! .serve(router) +//! .await; +//! }); +//! } +//! } +//! +//! fn app(cx: Scope) -> Element { +//! let text = use_state(cx, || "...".to_string()); +//! +//! cx.render(rsx! { +//! button { +//! onclick: move |_| { +//! to_owned![text]; +//! async move { +//! if let Ok(data) = get_server_data().await { +//! text.set(data); +//! } +//! } +//! }, +//! "Run a server function" +//! } +//! "Server said: {text}" +//! }) +//! } +//! +//! #[server(GetServerData)] +//! async fn get_server_data() -> Result { +//! Ok("Hello from the server!".to_string()) +//! } +//! ``` + +use dioxus_core::VirtualDom; +use hyper::{http::HeaderValue, StatusCode}; +use salvo::{ + async_trait, handler, + serve_static::{StaticDir, StaticFile}, + Depot, FlowCtrl, Handler, Request, Response, Router, +}; +use server_fn::{Encoding, Payload, ServerFunctionRegistry}; +use std::error::Error; +use std::sync::Arc; +use tokio::task::spawn_blocking; + +use crate::{ + prelude::*, render::SSRState, serve_config::ServeConfig, server_fn::DioxusServerFnRegistry, +}; + +/// A extension trait with utilities for integrating Dioxus with your Salvo router. +pub trait DioxusRouterExt { + /// Registers server functions with a custom handler function. This allows you to pass custom context to your server functions by generating a [`DioxusServerContext`] from the request. + /// + /// # Example + /// ```rust + /// use salvo::prelude::*; + /// use std::{net::TcpListener, sync::Arc}; + /// use dioxus_fullstack::prelude::*; + /// + /// struct ServerFunctionHandler { + /// server_fn: ServerFunction, + /// } + /// + /// #[handler] + /// impl ServerFunctionHandler { + /// async fn handle( + /// &self, + /// req: &mut Request, + /// depot: &mut Depot, + /// res: &mut Response, + /// flow: &mut FlowCtrl, + /// ) { + /// // Add the headers to server context + /// ServerFnHandler::new((req.headers().clone(),), self.server_fn.clone()) + /// .handle(req, depot, res, flow) + /// .await + /// } + /// } + /// + /// #[tokio::main] + /// async fn main() { + /// let router = Router::new() + /// .register_server_fns_with_handler("", |func| { + /// ServerFnHandler::new(DioxusServerContext::default(), func) + /// }); + /// Server::new(TcpListener::bind("127.0.0.1:8080")) + /// .serve(router) + /// .await; + /// } + /// ``` + fn register_server_fns_with_handler( + self, + server_fn_route: &'static str, + handler: impl Fn(ServerFunction) -> H, + ) -> Self + where + H: Handler + 'static; + + /// Registers server functions with the default handler. This handler function will pass an empty [`DioxusServerContext`] to your server functions. + /// + /// # Example + /// ```rust + /// use salvo::prelude::*; + /// use std::{net::TcpListener, sync::Arc}; + /// use dioxus_fullstack::prelude::*; + /// + /// #[tokio::main] + /// async fn main() { + /// let router = Router::new() + /// .register_server_fns(""); + /// Server::new(TcpListener::bind("127.0.0.1:8080")) + /// .serve(router) + /// .await; + /// } + /// + /// ``` + fn register_server_fns(self, server_fn_route: &'static str) -> Self; + + /// Register the web RSX hot reloading endpoint. This will enable hot reloading for your application in debug mode when you call [`dioxus_hot_reload::hot_reload_init`]. + /// + /// # Example + /// ```rust + /// use salvo::prelude::*; + /// use std::{net::TcpListener, sync::Arc}; + /// use dioxus_fullstack::prelude::*; + /// + /// #[tokio::main] + /// async fn main() { + /// let router = Router::new() + /// .connect_hot_reload(); + /// Server::new(TcpListener::bind("127.0.0.1:8080")) + /// .serve(router) + /// .await; + /// } + fn connect_hot_reload(self) -> Self; + + /// Serves the static WASM for your Dioxus application (except the generated index.html). + /// + /// # Example + /// ```rust + /// use salvo::prelude::*; + /// use std::{net::TcpListener, sync::Arc}; + /// use dioxus_fullstack::prelude::*; + /// + /// #[tokio::main] + /// async fn main() { + /// let router = Router::new() + /// .server_static_assets("/dist"); + /// Server::new(TcpListener::bind("127.0.0.1:8080")) + /// .serve(router) + /// .await; + /// } + /// ``` + fn serve_static_assets(self, assets_path: impl Into) -> Self; + + /// Serves the Dioxus application. This will serve a complete server side rendered application. + /// This will serve static assets, server render the application, register server functions, and intigrate with hot reloading. + /// + /// # Example + /// ```rust + /// #![allow(non_snake_case)] + /// use dioxus::prelude::*; + /// use dioxus_fullstack::prelude::*; + /// use salvo::prelude::*; + /// use std::{net::TcpListener, sync::Arc}; + /// + /// #[tokio::main] + /// async fn main() { + /// let router = Router::new().serve_dioxus_application("", ServeConfigBuilder::new(app, ())); + /// Server::new(TcpListener::bind("127.0.0.1:8080")) + /// .serve(router) + /// .await; + /// } + /// + /// fn app(cx: Scope) -> Element {todo!()} + /// ``` + fn serve_dioxus_application( + self, + server_fn_path: &'static str, + cfg: impl Into>, + ) -> Self; +} + +impl DioxusRouterExt for Router { + fn register_server_fns_with_handler( + self, + server_fn_route: &'static str, + mut handler: impl FnMut(ServerFunction) -> H, + ) -> Self + where + H: Handler + 'static, + { + let mut router = self; + for server_fn_path in DioxusServerFnRegistry::paths_registered() { + let func = DioxusServerFnRegistry::get(server_fn_path).unwrap(); + let full_route = format!("{server_fn_route}/{server_fn_path}"); + match func.encoding { + Encoding::Url | Encoding::Cbor => { + router = router.push(Router::with_path(&full_route).post(handler(func))); + } + Encoding::GetJSON | Encoding::GetCBOR => { + router = router.push(Router::with_path(&full_route).get(handler(func))); + } + } + } + router + } + + fn register_server_fns(self, server_fn_route: &'static str) -> Self { + self.register_server_fns_with_handler(server_fn_route, |func| ServerFnHandler { + server_context: DioxusServerContext::default(), + function: func, + }) + } + + fn serve_static_assets(mut self, assets_path: impl Into) -> Self { + let assets_path = assets_path.into(); + + // Serve all files in dist folder except index.html + let dir = std::fs::read_dir(&assets_path).unwrap_or_else(|e| { + panic!( + "Couldn't read assets directory at {:?}: {}", + &assets_path, e + ) + }); + + for entry in dir.flatten() { + let path = entry.path(); + if path.ends_with("index.html") { + continue; + } + let route = path + .strip_prefix(&assets_path) + .unwrap() + .iter() + .map(|segment| { + segment.to_str().unwrap_or_else(|| { + panic!("Failed to convert path segment {:?} to string", segment) + }) + }) + .collect::>() + .join("/"); + if path.is_file() { + let route = format!("/{}", route); + let serve_dir = StaticFile::new(path.clone()); + self = self.push(Router::with_path(route).get(serve_dir)) + } else { + let route = format!("/{}/<**path>", route); + let serve_dir = StaticDir::new([path.clone()]); + self = self.push(Router::with_path(route).get(serve_dir)) + } + } + + self + } + + fn serve_dioxus_application( + self, + server_fn_path: &'static str, + cfg: impl Into>, + ) -> Self { + let cfg = cfg.into(); + + self.serve_static_assets(&cfg.assets_path) + .connect_hot_reload() + .register_server_fns(server_fn_path) + .push(Router::with_path("/").get(SSRHandler { cfg })) + } + + fn connect_hot_reload(self) -> Self { + let mut _dioxus_router = Router::with_path("_dioxus"); + _dioxus_router = _dioxus_router + .push(Router::with_path("hot_reload").handle(HotReloadHandler::default())); + #[cfg(all(debug_assertions, feature = "hot-reload", feature = "ssr"))] + { + _dioxus_router = _dioxus_router.push(Router::with_path("disconnect").handle(ignore_ws)); + } + self.push(_dioxus_router) + } +} + +/// Extracts the parts of a request that are needed for server functions. This will take parts of the request and replace them with empty values. +pub fn extract_parts(req: &mut Request) -> RequestParts { + RequestParts { + method: std::mem::take(req.method_mut()), + uri: std::mem::take(req.uri_mut()), + version: req.version(), + headers: std::mem::take(req.headers_mut()), + extensions: std::mem::take(req.extensions_mut()), + } +} + +struct SSRHandler { + cfg: ServeConfig

, +} + +#[async_trait] +impl Handler for SSRHandler

{ + async fn handle( + &self, + req: &mut Request, + depot: &mut Depot, + res: &mut Response, + _flow: &mut FlowCtrl, + ) { + // Get the SSR renderer from the depot or create a new one if it doesn't exist + let renderer_pool = if let Some(renderer) = depot.obtain::() { + renderer.clone() + } else { + let renderer = SSRState::default(); + depot.inject(renderer.clone()); + renderer + }; + let parts: Arc = Arc::new(extract_parts(req)); + let server_context = DioxusServerContext::new(parts); + let mut vdom = VirtualDom::new_with_props(self.cfg.app, self.cfg.props.clone()) + .with_root_context(server_context.clone()); + let _ = vdom.rebuild(); + + res.write_body(renderer_pool.render_vdom(&vdom, &self.cfg)) + .unwrap(); + + *res.headers_mut() = server_context.take_response_headers(); + } +} + +/// A default handler for server functions. It will deserialize the request body, call the server function, and serialize the response. +pub struct ServerFnHandler { + server_context: DioxusServerContext, + function: ServerFunction, +} + +impl ServerFnHandler { + /// Create a new server function handler with the given server context and server function. + pub fn new(server_context: impl Into, function: ServerFunction) -> Self { + let server_context = server_context.into(); + Self { + server_context, + function, + } + } +} + +#[handler] +impl ServerFnHandler { + async fn handle(&self, req: &mut Request, _depot: &mut Depot, res: &mut Response) { + let Self { + server_context, + function, + } = self; + + let query = req + .uri() + .query() + .unwrap_or_default() + .as_bytes() + .to_vec() + .into(); + let body = hyper::body::to_bytes(req.body_mut().unwrap()).await; + let Ok(body)=body else { + handle_error(body.err().unwrap(), res); + return; + }; + let headers = req.headers(); + let accept_header = headers.get("Accept").cloned(); + + let parts = Arc::new(extract_parts(req)); + + // Because the future returned by `server_fn_handler` is `Send`, and the future returned by this function must be send, we need to spawn a new runtime + let (resp_tx, resp_rx) = tokio::sync::oneshot::channel(); + spawn_blocking({ + let function = function.clone(); + let mut server_context = server_context.clone(); + server_context.parts = parts; + move || { + tokio::runtime::Runtime::new() + .expect("couldn't spawn runtime") + .block_on(async move { + let data = match &function.encoding { + Encoding::Url | Encoding::Cbor => &body, + Encoding::GetJSON | Encoding::GetCBOR => &query, + }; + let resp = (function.trait_obj)(server_context, data).await; + + resp_tx.send(resp).unwrap(); + }) + } + }); + let result = resp_rx.await.unwrap(); + + // Set the headers from the server context + *res.headers_mut() = server_context.take_response_headers(); + + match result { + Ok(serialized) => { + // if this is Accept: application/json then send a serialized JSON response + let accept_header = accept_header.as_ref().and_then(|value| value.to_str().ok()); + if accept_header == Some("application/json") + || accept_header + == Some( + "application/\ + x-www-form-urlencoded", + ) + || accept_header == Some("application/cbor") + { + res.set_status_code(StatusCode::OK); + } + + match serialized { + Payload::Binary(data) => { + res.headers_mut() + .insert("Content-Type", HeaderValue::from_static("application/cbor")); + res.write_body(data).unwrap(); + } + Payload::Url(data) => { + res.headers_mut().insert( + "Content-Type", + HeaderValue::from_static( + "application/\ + x-www-form-urlencoded", + ), + ); + res.write_body(data).unwrap(); + } + Payload::Json(data) => { + res.headers_mut() + .insert("Content-Type", HeaderValue::from_static("application/json")); + res.write_body(data).unwrap(); + } + } + } + Err(err) => handle_error(err, res), + } + } +} + +fn handle_error(error: impl Error + Send + Sync, res: &mut Response) { + let mut resp_err = Response::new(); + resp_err.set_status_code(StatusCode::INTERNAL_SERVER_ERROR); + resp_err.render(format!("Internal Server Error: {}", error)); + *res = resp_err; +} + +/// A handler for Dioxus web hot reload websocket. This will send the updated static parts of the RSX to the client when they change. +#[cfg(not(all(debug_assertions, feature = "hot-reload", feature = "ssr")))] +#[derive(Default)] +pub struct HotReloadHandler; + +#[cfg(not(all(debug_assertions, feature = "hot-reload", feature = "ssr")))] +#[handler] +impl HotReloadHandler { + async fn handle( + &self, + _req: &mut Request, + _depot: &mut Depot, + _res: &mut Response, + ) -> Result<(), salvo::http::StatusError> { + Err(salvo::http::StatusError::not_found()) + } +} + +/// A handler for Dioxus web hot reload websocket. This will send the updated static parts of the RSX to the client when they change. +#[cfg(all(debug_assertions, feature = "hot-reload", feature = "ssr"))] +#[derive(Default)] +pub struct HotReloadHandler; + +#[cfg(all(debug_assertions, feature = "hot-reload", feature = "ssr"))] +#[handler] +impl HotReloadHandler { + async fn handle( + &self, + req: &mut Request, + _depot: &mut Depot, + res: &mut Response, + ) -> Result<(), salvo::http::StatusError> { + use salvo::ws::Message; + use salvo::ws::WebSocketUpgrade; + + let state = crate::hot_reload::spawn_hot_reload().await; + + WebSocketUpgrade::new() + .upgrade(req, res, move |mut websocket| async move { + use futures_util::StreamExt; + + println!("🔥 Hot Reload WebSocket connected"); + { + // update any rsx calls that changed before the websocket connected. + { + println!("🔮 Finding updates since last compile..."); + let templates_read = state.templates.read().await; + + for template in &*templates_read { + if websocket + .send(Message::text(serde_json::to_string(&template).unwrap())) + .await + .is_err() + { + return; + } + } + } + println!("finished"); + } + + let mut rx = tokio_stream::wrappers::WatchStream::from_changes( + state.message_receiver.clone(), + ); + while let Some(change) = rx.next().await { + if let Some(template) = change { + let template = { serde_json::to_string(&template).unwrap() }; + if websocket.send(Message::text(template)).await.is_err() { + break; + }; + } + } + }) + .await + } +} + +#[cfg(all(debug_assertions, feature = "hot-reload", feature = "ssr"))] +#[handler] +async fn ignore_ws(req: &mut Request, res: &mut Response) -> Result<(), salvo::http::StatusError> { + use salvo::ws::WebSocketUpgrade; + WebSocketUpgrade::new() + .upgrade(req, res, |mut ws| async move { + let _ = ws.send(salvo::ws::Message::text("connected")).await; + while let Some(msg) = ws.recv().await { + if msg.is_err() { + return; + }; + } + }) + .await +} diff --git a/packages/fullstack/src/adapters/warp_adapter.rs b/packages/fullstack/src/adapters/warp_adapter.rs new file mode 100644 index 000000000..872a94cdd --- /dev/null +++ b/packages/fullstack/src/adapters/warp_adapter.rs @@ -0,0 +1,444 @@ +//! Dioxus utilities for the [Warp](https://docs.rs/warp/latest/warp/index.html) server framework. +//! +//! # Example +//! ```rust +//! #![allow(non_snake_case)] +//! use dioxus::prelude::*; +//! use dioxus_fullstack::prelude::*; +//! +//! fn main() { +//! #[cfg(feature = "web")] +//! dioxus_web::launch_cfg(app, dioxus_web::Config::new().hydrate(true)); +//! #[cfg(feature = "ssr")] +//! { +//! GetServerData::register().unwrap(); +//! tokio::runtime::Runtime::new() +//! .unwrap() +//! .block_on(async move { +//! let routes = serve_dioxus_application("", ServeConfigBuilder::new(app, ())); +//! warp::serve(routes).run(([127, 0, 0, 1], 8080)).await; +//! }); +//! } +//! } +//! +//! fn app(cx: Scope) -> Element { +//! let text = use_state(cx, || "...".to_string()); +//! +//! cx.render(rsx! { +//! button { +//! onclick: move |_| { +//! to_owned![text]; +//! async move { +//! if let Ok(data) = get_server_data().await { +//! text.set(data); +//! } +//! } +//! }, +//! "Run a server function" +//! } +//! "Server said: {text}" +//! }) +//! } +//! +//! #[server(GetServerData)] +//! async fn get_server_data() -> Result { +//! Ok("Hello from the server!".to_string()) +//! } +//! +//! ``` + +use crate::{ + prelude::*, render::SSRState, serve_config::ServeConfig, server_fn::DioxusServerFnRegistry, +}; + +use dioxus_core::VirtualDom; +use server_fn::{Encoding, Payload, ServerFunctionRegistry}; +use std::error::Error; +use std::sync::Arc; +use tokio::task::spawn_blocking; +use warp::path::FullPath; +use warp::{ + filters::BoxedFilter, + http::{Response, StatusCode}, + hyper::body::Bytes, + path, Filter, Reply, +}; + +/// Registers server functions with a custom handler function. This allows you to pass custom context to your server functions by generating a [`DioxusServerContext`] from the request. +/// +/// # Example +/// ```rust +/// use warp::{body, header, hyper::HeaderMap, path, post, Filter}; +/// +/// #[tokio::main] +/// async fn main() { +/// let routes = register_server_fns_with_handler("", |full_route, func| { +/// path(full_route) +/// .and(post()) +/// .and(header::headers_cloned()) +/// .and(body::bytes()) +/// .and_then(move |headers: HeaderMap, body| { +/// let func = func.clone(); +/// async move { +/// // Add the headers to the server function context +/// server_fn_handler((headers.clone(),), func, headers, body).await +/// } +/// }) +/// }); +/// warp::serve(routes).run(([127, 0, 0, 1], 8080)).await; +/// } +/// ``` +pub fn register_server_fns_with_handler( + server_fn_route: &'static str, + mut handler: H, +) -> BoxedFilter<(R,)> +where + H: FnMut(String, ServerFunction) -> F, + F: Filter + Send + Sync + 'static, + F::Extract: Send, + R: Reply + 'static, +{ + let mut filter: Option> = None; + for server_fn_path in DioxusServerFnRegistry::paths_registered() { + let func = DioxusServerFnRegistry::get(server_fn_path).unwrap(); + let full_route = format!("{server_fn_route}/{server_fn_path}") + .trim_start_matches('/') + .to_string(); + let route = handler(full_route, func.clone()).boxed(); + if let Some(boxed_filter) = filter.take() { + filter = Some(boxed_filter.or(route).unify().boxed()); + } else { + filter = Some(route); + } + } + filter.expect("No server functions found") +} + +/// Registers server functions with the default handler. This handler function will pass an empty [`DioxusServerContext`] to your server functions. +/// +/// # Example +/// ```rust +/// use dioxus_fullstack::prelude::*; +/// +/// #[tokio::main] +/// async fn main() { +/// let routes = register_server_fns(""); +/// warp::serve(routes).run(([127, 0, 0, 1], 8080)).await; +/// } +/// ``` +pub fn register_server_fns(server_fn_route: &'static str) -> BoxedFilter<(impl Reply,)> { + register_server_fns_with_handler(server_fn_route, |full_route, func| { + path(full_route.clone()) + .and(warp::post().or(warp::get()).unify()) + .and(request_parts()) + .and(warp::body::bytes()) + .and_then(move |parts, bytes| { + let func = func.clone(); + async move { + server_fn_handler(DioxusServerContext::default(), func, parts, bytes).await + } + }) + }) +} + +/// Serves the Dioxus application. This will serve a complete server side rendered application. +/// This will serve static assets, server render the application, register server functions, and intigrate with hot reloading. +/// +/// # Example +/// ```rust +/// #![allow(non_snake_case)] +/// use dioxus::prelude::*; +/// use dioxus_fullstack::prelude::*; +/// +/// #[tokio::main] +/// async fn main() { +/// let routes = serve_dioxus_application("", ServeConfigBuilder::new(app, ())); +/// warp::serve(routes).run(([127, 0, 0, 1], 8080)).await; +/// } +/// +/// fn app(cx: Scope) -> Element { +/// todo!() +/// } +/// ``` +pub fn serve_dioxus_application( + server_fn_route: &'static str, + cfg: impl Into>, +) -> BoxedFilter<(impl Reply,)> { + let cfg = cfg.into(); + // Serve the dist folder and the index.html file + let serve_dir = warp::fs::dir(cfg.assets_path); + + connect_hot_reload() + .or(register_server_fns(server_fn_route)) + .or(warp::path::end().and(render_ssr(cfg))) + .or(serve_dir) + .boxed() +} + +/// Server render the application. +pub fn render_ssr( + cfg: ServeConfig

, +) -> impl Filter + Clone { + warp::get() + .and(request_parts()) + .and(with_ssr_state()) + .map(move |parts, renderer: SSRState| { + let parts = Arc::new(parts); + + let server_context = DioxusServerContext::new(parts); + + let mut vdom = VirtualDom::new_with_props(cfg.app, cfg.props.clone()) + .with_root_context(server_context.clone()); + let _ = vdom.rebuild(); + + let html = renderer.render_vdom(&vdom, &cfg); + + let mut res = Response::builder(); + + *res.headers_mut().expect("empty request should be valid") = + server_context.take_response_headers(); + + res.header("Content-Type", "text/html") + .body(Bytes::from(html)) + .unwrap() + }) +} + +/// An extractor for the request parts (used in [DioxusServerContext]). This will extract the method, uri, query, and headers from the request. +pub fn request_parts( +) -> impl Filter + Clone { + warp::method() + .and(warp::filters::path::full()) + .and( + warp::filters::query::raw() + .or(warp::any().map(String::new)) + .unify(), + ) + .and(warp::header::headers_cloned()) + .and_then(move |method, path: FullPath, query, headers| async move { + http::uri::Builder::new() + .path_and_query(format!("{}?{}", path.as_str(), query)) + .build() + .map_err(|err| { + warp::reject::custom(FailedToReadBody(format!("Failed to build uri: {}", err))) + }) + .map(|uri| RequestParts { + method, + uri, + headers, + ..Default::default() + }) + }) +} + +fn with_ssr_state() -> impl Filter + Clone +{ + let state = SSRState::default(); + warp::any().map(move || state.clone()) +} + +#[derive(Debug)] +struct FailedToReadBody(String); + +impl warp::reject::Reject for FailedToReadBody {} + +#[derive(Debug)] +struct RecieveFailed(String); + +impl warp::reject::Reject for RecieveFailed {} + +/// A default handler for server functions. It will deserialize the request body, call the server function, and serialize the response. +pub async fn server_fn_handler( + server_context: impl Into, + function: ServerFunction, + parts: RequestParts, + body: Bytes, +) -> Result, warp::Rejection> { + let mut server_context = server_context.into(); + + let parts = Arc::new(parts); + + server_context.parts = parts.clone(); + + // Because the future returned by `server_fn_handler` is `Send`, and the future returned by this function must be send, we need to spawn a new runtime + let (resp_tx, resp_rx) = tokio::sync::oneshot::channel(); + spawn_blocking({ + move || { + tokio::runtime::Runtime::new() + .expect("couldn't spawn runtime") + .block_on(async move { + let query = parts + .uri + .query() + .unwrap_or_default() + .as_bytes() + .to_vec() + .into(); + let data = match &function.encoding { + Encoding::Url | Encoding::Cbor => &body, + Encoding::GetJSON | Encoding::GetCBOR => &query, + }; + let resp = match (function.trait_obj)(server_context.clone(), data).await { + Ok(serialized) => { + // if this is Accept: application/json then send a serialized JSON response + let accept_header = parts + .headers + .get("Accept") + .as_ref() + .and_then(|value| value.to_str().ok()); + let mut res = Response::builder(); + + *res.headers_mut().expect("empty request should be valid") = + server_context.take_response_headers(); + + if accept_header == Some("application/json") + || accept_header + == Some( + "application/\ + x-www-form-urlencoded", + ) + || accept_header == Some("application/cbor") + { + res = res.status(StatusCode::OK); + } + + let resp = match serialized { + Payload::Binary(data) => res + .header("Content-Type", "application/cbor") + .body(Bytes::from(data)), + Payload::Url(data) => res + .header( + "Content-Type", + "application/\ + x-www-form-urlencoded", + ) + .body(Bytes::from(data)), + Payload::Json(data) => res + .header("Content-Type", "application/json") + .body(Bytes::from(data)), + }; + + Box::new(resp.unwrap()) + } + Err(e) => report_err(e), + }; + + if resp_tx.send(resp).is_err() { + eprintln!("Error sending response"); + } + }) + } + }); + resp_rx.await.map_err(|err| { + warp::reject::custom(RecieveFailed(format!("Failed to recieve response {err}"))) + }) +} + +fn report_err(e: E) -> Box { + Box::new( + Response::builder() + .status(StatusCode::INTERNAL_SERVER_ERROR) + .body(format!("Error: {}", e)) + .unwrap(), + ) as Box +} + +/// Register the web RSX hot reloading endpoint. This will enable hot reloading for your application in debug mode when you call [`dioxus_hot_reload::hot_reload_init`]. +/// +/// # Example +/// ```rust +/// #![allow(non_snake_case)] +/// use dioxus_fullstack::prelude::*; +/// +/// #[tokio::main] +/// async fn main() { +/// let routes = connect_hot_reload(); +/// warp::serve(routes).run(([127, 0, 0, 1], 8080)).await; +/// } +/// ``` +pub fn connect_hot_reload() -> impl Filter + Clone +{ + #[cfg(not(all(debug_assertions, feature = "hot-reload", feature = "ssr")))] + { + warp::path!("_dioxus" / "hot_reload") + .and(warp::ws()) + .map(warp::reply) + .map(|reply| warp::reply::with_status(reply, warp::http::StatusCode::NOT_FOUND)); + } + #[cfg(all(debug_assertions, feature = "hot-reload", feature = "ssr"))] + { + use crate::hot_reload::HotReloadState; + use futures_util::sink::SinkExt; + use futures_util::StreamExt; + use warp::ws::Message; + + let hot_reload = warp::path!("_dioxus" / "hot_reload") + .and(warp::any().then(|| crate::hot_reload::spawn_hot_reload())) + .and(warp::ws()) + .map(move |state: &'static HotReloadState, ws: warp::ws::Ws| { + #[cfg(all(debug_assertions, feature = "hot-reload", feature = "ssr"))] + ws.on_upgrade(move |mut websocket| { + async move { + println!("🔥 Hot Reload WebSocket connected"); + { + // update any rsx calls that changed before the websocket connected. + { + println!("🔮 Finding updates since last compile..."); + let templates_read = state.templates.read().await; + + for template in &*templates_read { + if websocket + .send(Message::text( + serde_json::to_string(&template).unwrap(), + )) + .await + .is_err() + { + return; + } + } + } + println!("finished"); + } + + let mut rx = tokio_stream::wrappers::WatchStream::from_changes( + state.message_receiver.clone(), + ); + while let Some(change) = rx.next().await { + if let Some(template) = change { + let template = { serde_json::to_string(&template).unwrap() }; + if websocket.send(Message::text(template)).await.is_err() { + break; + }; + } + } + } + }) + }); + let disconnect = + warp::path!("_dioxus" / "disconnect") + .and(warp::ws()) + .map(move |ws: warp::ws::Ws| { + println!("disconnect"); + #[cfg(all(debug_assertions, feature = "hot-reload", feature = "ssr"))] + ws.on_upgrade(move |mut websocket| async move { + struct DisconnectOnDrop(Option); + impl Drop for DisconnectOnDrop { + fn drop(&mut self) { + let _ = self.0.take().unwrap().close(); + } + } + + let _ = websocket.send(Message::text("connected")).await; + let mut ws = DisconnectOnDrop(Some(websocket)); + + loop { + if ws.0.as_mut().unwrap().next().await.is_none() { + break; + } + } + }) + }); + disconnect.or(hot_reload) + } +} diff --git a/packages/fullstack/src/hot_reload.rs b/packages/fullstack/src/hot_reload.rs new file mode 100644 index 000000000..31a05e1c9 --- /dev/null +++ b/packages/fullstack/src/hot_reload.rs @@ -0,0 +1,61 @@ +use std::sync::Arc; + +use dioxus_core::Template; +use tokio::sync::{ + watch::{channel, Receiver}, + RwLock, +}; + +#[derive(Clone)] +pub struct HotReloadState { + // The cache of all templates that have been modified since the last time we checked + pub(crate) templates: Arc>>>, + // The channel to send messages to the hot reload thread + pub(crate) message_receiver: Receiver>>, +} + +impl Default for HotReloadState { + fn default() -> Self { + let templates = Arc::new(RwLock::new(std::collections::HashSet::new())); + let (tx, rx) = channel(None); + + dioxus_hot_reload::connect({ + let templates = templates.clone(); + move |msg| match msg { + dioxus_hot_reload::HotReloadMsg::UpdateTemplate(template) => { + { + let mut templates = templates.blocking_write(); + templates.insert(template); + } + + if let Err(err) = tx.send(Some(template)) { + log::error!("Failed to send hot reload message: {}", err); + } + } + dioxus_hot_reload::HotReloadMsg::Shutdown => { + std::process::exit(0); + } + } + }); + + Self { + templates, + message_receiver: rx, + } + } +} + +// Hot reloading can be expensive to start so we spawn a new thread +static HOT_RELOAD_STATE: tokio::sync::OnceCell = tokio::sync::OnceCell::const_new(); +pub(crate) async fn spawn_hot_reload() -> &'static HotReloadState { + HOT_RELOAD_STATE + .get_or_init(|| async { + println!("spinning up hot reloading"); + let r = tokio::task::spawn_blocking(HotReloadState::default) + .await + .unwrap(); + println!("hot reloading ready"); + r + }) + .await +} diff --git a/packages/fullstack/src/lib.rs b/packages/fullstack/src/lib.rs new file mode 100644 index 000000000..be437bc53 --- /dev/null +++ b/packages/fullstack/src/lib.rs @@ -0,0 +1,42 @@ +#![doc = include_str!("../README.md")] +#![doc(html_logo_url = "https://avatars.githubusercontent.com/u/79236386")] +#![doc(html_favicon_url = "https://avatars.githubusercontent.com/u/79236386")] +#![deny(missing_docs)] + +pub use adapters::*; + +mod props_html; + +mod adapters; +#[cfg(all(debug_assertions, feature = "hot-reload", feature = "ssr"))] +mod hot_reload; +#[cfg(feature = "ssr")] +mod render; +#[cfg(feature = "ssr")] +mod serve_config; +mod server_context; +mod server_fn; + +/// A prelude of commonly used items in dioxus-fullstack. +pub mod prelude { + #[cfg(feature = "axum")] + pub use crate::adapters::axum_adapter::*; + #[cfg(feature = "salvo")] + pub use crate::adapters::salvo_adapter::*; + #[cfg(feature = "warp")] + pub use crate::adapters::warp_adapter::*; + #[cfg(not(feature = "ssr"))] + pub use crate::props_html::deserialize_props::get_root_props_from_document; + #[cfg(feature = "ssr")] + pub use crate::render::SSRState; + #[cfg(feature = "ssr")] + pub use crate::serve_config::{ServeConfig, ServeConfigBuilder}; + #[cfg(feature = "ssr")] + pub use crate::server_context::RequestParts; + pub use crate::server_context::{DioxusServerContext, HasServerContext}; + pub use crate::server_fn::ServerFn; + #[cfg(feature = "ssr")] + pub use crate::server_fn::{ServerFnTraitObj, ServerFunction}; + pub use dioxus_server_macro::*; + pub use server_fn::{self, ServerFn as _, ServerFnError}; +} diff --git a/packages/fullstack/src/props_html/deserialize_props.rs b/packages/fullstack/src/props_html/deserialize_props.rs new file mode 100644 index 000000000..35e54a1e5 --- /dev/null +++ b/packages/fullstack/src/props_html/deserialize_props.rs @@ -0,0 +1,32 @@ +use serde::de::DeserializeOwned; + +use base64::engine::general_purpose::STANDARD; +use base64::Engine; + +#[allow(unused)] +pub(crate) fn serde_from_string(string: &str) -> Option { + let decompressed = STANDARD.decode(string.as_bytes()).ok()?; + let (decompressed, _) = yazi::decompress(&decompressed, yazi::Format::Zlib).unwrap(); + + postcard::from_bytes(&decompressed).ok() +} + +#[cfg(not(feature = "ssr"))] +/// Get the props from the document. This is only available in the browser. +/// +/// When dioxus-fullstack renders the page, it will serialize the root props and put them in the document. This function gets them from the document. +pub fn get_root_props_from_document() -> Option { + #[cfg(not(target_arch = "wasm32"))] + { + None + } + #[cfg(target_arch = "wasm32")] + { + let attribute = web_sys::window()? + .document()? + .get_element_by_id("dioxus-storage")? + .get_attribute("data-serialized")?; + + serde_from_string(&attribute) + } +} diff --git a/packages/fullstack/src/props_html/mod.rs b/packages/fullstack/src/props_html/mod.rs new file mode 100644 index 000000000..048e2aa90 --- /dev/null +++ b/packages/fullstack/src/props_html/mod.rs @@ -0,0 +1,54 @@ +pub(crate) mod deserialize_props; + +pub(crate) mod serialize_props; + +#[test] +fn serialized_and_deserializes() { + use postcard::to_allocvec; + + #[derive(serde::Serialize, serde::Deserialize, Debug, PartialEq, Clone)] + struct Data { + a: u32, + b: String, + bytes: Vec, + nested: Nested, + } + + #[derive(serde::Serialize, serde::Deserialize, Debug, PartialEq, Clone)] + struct Nested { + a: u32, + b: u16, + c: u8, + } + + for x in 0..10usize { + for y in 0..10 { + let mut as_string = String::new(); + let data = vec![ + Data { + a: x as u32, + b: "hello".to_string(), + bytes: vec![0; x], + nested: Nested { + a: 1, + b: x as u16, + c: 3 + }, + }; + y + ]; + serialize_props::serde_to_writable(&data, &mut as_string).unwrap(); + + println!("{}", as_string); + println!( + "original size: {}", + std::mem::size_of::() * data.len() + ); + println!("serialized size: {}", to_allocvec(&data).unwrap().len()); + println!("compressed size: {}", as_string.len()); + + let decoded: Vec = deserialize_props::serde_from_string(&as_string).unwrap(); + assert_eq!(data, decoded); + } + } +} diff --git a/packages/fullstack/src/props_html/serialize_props.rs b/packages/fullstack/src/props_html/serialize_props.rs new file mode 100644 index 000000000..0012afb9c --- /dev/null +++ b/packages/fullstack/src/props_html/serialize_props.rs @@ -0,0 +1,31 @@ +use serde::Serialize; + +use base64::engine::general_purpose::STANDARD; +use base64::Engine; + +#[allow(unused)] +pub(crate) fn serde_to_writable( + value: &T, + mut write_to: impl std::fmt::Write, +) -> std::fmt::Result { + let serialized = postcard::to_allocvec(value).unwrap(); + let compressed = yazi::compress( + &serialized, + yazi::Format::Zlib, + yazi::CompressionLevel::BestSize, + ) + .unwrap(); + write_to.write_str(&STANDARD.encode(compressed)); + Ok(()) +} + +#[cfg(feature = "ssr")] +/// Encode data into a element. This is inteded to be used in the server to send data to the client. +pub(crate) fn encode_in_element( + data: T, + mut write_to: impl std::fmt::Write, +) -> std::fmt::Result { + write_to.write_str(r#""#) +} diff --git a/packages/fullstack/src/render.rs b/packages/fullstack/src/render.rs new file mode 100644 index 000000000..b56016d5e --- /dev/null +++ b/packages/fullstack/src/render.rs @@ -0,0 +1,100 @@ +//! A shared pool of renderers for efficient server side rendering. + +use std::sync::Arc; + +use dioxus_core::VirtualDom; +use dioxus_ssr::Renderer; + +use crate::prelude::ServeConfig; + +/// State used in server side rendering. This utilizes a pool of [`dioxus_ssr::Renderer`]s to cache static templates between renders. +#[derive(Clone)] +pub struct SSRState { + // We keep a pool of renderers to avoid re-creating them on every request. They are boxed to make them very cheap to move + renderers: Arc>, +} + +impl Default for SSRState { + fn default() -> Self { + Self { + renderers: Arc::new(object_pool::Pool::new(10, pre_renderer)), + } + } +} + +impl SSRState { + /// Render the application to HTML. + pub fn render(&self, cfg: &ServeConfig

) -> String { + let ServeConfig { app, props, .. } = cfg; + + let mut vdom = VirtualDom::new_with_props(*app, props.clone()); + + let _ = vdom.rebuild(); + + self.render_vdom(&vdom, cfg) + } + + /// Render a VirtualDom to HTML. + pub fn render_vdom( + &self, + vdom: &VirtualDom, + cfg: &ServeConfig

, + ) -> String { + let ServeConfig { index, .. } = cfg; + + let mut renderer = self.renderers.pull(pre_renderer); + + let mut html = String::new(); + + html += &index.pre_main; + + let _ = renderer.render_to(&mut html, vdom); + + // serialize the props + let _ = crate::props_html::serialize_props::encode_in_element(&cfg.props, &mut html); + + #[cfg(all(debug_assertions, feature = "hot-reload"))] + { + // In debug mode, we need to add a script to the page that will reload the page if the websocket disconnects to make full recompile hot reloads work + let disconnect_js = r#"(function () { + const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; + const url = protocol + '//' + window.location.host + '/_dioxus/disconnect'; + const poll_interval = 1000; + const reload_upon_connect = () => { + console.log('Disconnected from server. Attempting to reconnect...'); + window.setTimeout( + () => { + // Try to reconnect to the websocket + const ws = new WebSocket(url); + ws.onopen = () => { + // If we reconnect, reload the page + window.location.reload(); + } + // Otherwise, try again in a second + reload_upon_connect(); + }, + poll_interval); + }; + + // on initial page load connect to the disconnect ws + const ws = new WebSocket(url); + // if we disconnect, start polling + ws.onclose = reload_upon_connect; +})()"#; + + html += r#""#; + } + + html += &index.post_main; + + html + } +} + +fn pre_renderer() -> Renderer { + let mut renderer = Renderer::default(); + renderer.pre_render = true; + renderer +} diff --git a/packages/fullstack/src/serve_config.rs b/packages/fullstack/src/serve_config.rs new file mode 100644 index 000000000..b7b29e9fb --- /dev/null +++ b/packages/fullstack/src/serve_config.rs @@ -0,0 +1,115 @@ +//! Configeration for how to serve a Dioxus application + +use std::fs::File; +use std::io::Read; +use std::path::PathBuf; + +use dioxus_core::Component; + +/// A ServeConfig is used to configure how to serve a Dioxus application. It contains information about how to serve static assets, and what content to render with [`dioxus-ssr`]. +#[derive(Clone)] +pub struct ServeConfigBuilder { + pub(crate) app: Component

, + pub(crate) props: P, + pub(crate) root_id: Option<&'static str>, + pub(crate) index_path: Option<&'static str>, + pub(crate) assets_path: Option<&'static str>, +} + +impl ServeConfigBuilder

{ + /// Create a new ServeConfigBuilder with the root component and props to render on the server. + pub fn new(app: Component

, props: P) -> Self { + Self { + app, + props, + root_id: None, + index_path: None, + assets_path: None, + } + } + + /// Set the path of the index.html file to be served. (defaults to {assets_path}/index.html) + pub fn index_path(mut self, index_path: &'static str) -> Self { + self.index_path = Some(index_path); + self + } + + /// Set the id of the root element in the index.html file to place the prerendered content into. (defaults to main) + pub fn root_id(mut self, root_id: &'static str) -> Self { + self.root_id = Some(root_id); + self + } + + /// Set the path of the assets folder generated by the Dioxus CLI. (defaults to dist) + pub fn assets_path(mut self, assets_path: &'static str) -> Self { + self.assets_path = Some(assets_path); + self + } + + /// Build the ServeConfig + pub fn build(self) -> ServeConfig

{ + let assets_path = self.assets_path.unwrap_or("dist"); + + let index_path = self + .index_path + .map(PathBuf::from) + .unwrap_or_else(|| format!("{assets_path}/index.html").into()); + + let root_id = self.root_id.unwrap_or("main"); + + let index = load_index_html(index_path, root_id); + + ServeConfig { + app: self.app, + props: self.props, + index, + assets_path, + } + } +} + +fn load_index_html(path: PathBuf, root_id: &'static str) -> IndexHtml { + let mut file = File::open(path).expect("Failed to find index.html. Make sure the index_path is set correctly and the WASM application has been built."); + + let mut contents = String::new(); + file.read_to_string(&mut contents) + .expect("Failed to read index.html"); + + let (pre_main, post_main) = contents.split_once(&format!("id=\"{root_id}\"")).unwrap_or_else(|| panic!("Failed to find id=\"{root_id}\" in index.html. The id is used to inject the application into the page.")); + + let post_main = post_main.split_once('>').unwrap_or_else(|| { + panic!("Failed to find closing > after id=\"{root_id}\" in index.html.") + }); + + let (pre_main, post_main) = ( + pre_main.to_string() + &format!("id=\"{root_id}\"") + post_main.0 + ">", + post_main.1.to_string(), + ); + + IndexHtml { + pre_main, + post_main, + } +} + +#[derive(Clone)] +pub(crate) struct IndexHtml { + pub(crate) pre_main: String, + pub(crate) post_main: String, +} + +/// Used to configure how to serve a Dioxus application. It contains information about how to serve static assets, and what content to render with [`dioxus-ssr`]. +/// See [`ServeConfigBuilder`] to create a ServeConfig +#[derive(Clone)] +pub struct ServeConfig { + pub(crate) app: Component

, + pub(crate) props: P, + pub(crate) index: IndexHtml, + pub(crate) assets_path: &'static str, +} + +impl From> for ServeConfig

{ + fn from(builder: ServeConfigBuilder

) -> Self { + builder.build() + } +} diff --git a/packages/fullstack/src/server_context.rs b/packages/fullstack/src/server_context.rs new file mode 100644 index 000000000..f225526ec --- /dev/null +++ b/packages/fullstack/src/server_context.rs @@ -0,0 +1,160 @@ +use dioxus_core::ScopeState; + +/// A trait for an object that contains a server context +pub trait HasServerContext { + /// Get the server context from the state + fn server_context(&self) -> DioxusServerContext; + + /// A shortcut for `self.server_context()` + fn sc(&self) -> DioxusServerContext { + self.server_context() + } +} + +impl HasServerContext for &ScopeState { + fn server_context(&self) -> DioxusServerContext { + #[cfg(feature = "ssr")] + { + self.consume_context().expect("No server context found") + } + #[cfg(not(feature = "ssr"))] + { + DioxusServerContext {} + } + } +} + +/// A shared context for server functions that contains infomation about the request and middleware state. +/// This allows you to pass data between your server framework and the server functions. This can be used to pass request information or information about the state of the server. For example, you could pass authentication data though this context to your server functions. +/// +/// You should not construct this directly inside components. Instead use the `HasServerContext` trait to get the server context from the scope. +#[derive(Clone)] +pub struct DioxusServerContext { + #[cfg(feature = "ssr")] + shared_context: std::sync::Arc< + std::sync::RwLock>, + >, + #[cfg(feature = "ssr")] + headers: std::sync::Arc>, + #[cfg(feature = "ssr")] + pub(crate) parts: std::sync::Arc, +} + +#[allow(clippy::derivable_impls)] +impl Default for DioxusServerContext { + fn default() -> Self { + Self { + #[cfg(feature = "ssr")] + shared_context: std::sync::Arc::new(std::sync::RwLock::new(anymap::Map::new())), + #[cfg(feature = "ssr")] + headers: Default::default(), + #[cfg(feature = "ssr")] + parts: Default::default(), + } + } +} + +#[cfg(feature = "ssr")] +pub use server_fn_impl::*; + +#[cfg(feature = "ssr")] +mod server_fn_impl { + use super::*; + use std::sync::LockResult; + use std::sync::{Arc, PoisonError, RwLock, RwLockReadGuard, RwLockWriteGuard}; + + use anymap::{any::Any, Map}; + type SendSyncAnyMap = Map; + + impl DioxusServerContext { + /// Create a new server context from a request + pub fn new(parts: impl Into>) -> Self { + Self { + parts: parts.into(), + shared_context: Arc::new(RwLock::new(SendSyncAnyMap::new())), + headers: Default::default(), + } + } + + /// Clone a value from the shared server context + pub fn get(&self) -> Option { + self.shared_context.read().ok()?.get::().cloned() + } + + /// Insert a value into the shared server context + pub fn insert( + &mut self, + value: T, + ) -> Result<(), PoisonError>> { + self.shared_context + .write() + .map(|mut map| map.insert(value)) + .map(|_| ()) + } + + /// Get the headers from the server context + pub fn response_headers(&self) -> RwLockReadGuard<'_, hyper::header::HeaderMap> { + self.try_response_headers() + .expect("Failed to get headers from server context") + } + + /// Try to get the headers from the server context + pub fn try_response_headers( + &self, + ) -> LockResult> { + self.headers.read() + } + + /// Get the headers mutably from the server context + pub fn response_headers_mut(&self) -> RwLockWriteGuard<'_, hyper::header::HeaderMap> { + self.try_response_headers_mut() + .expect("Failed to get headers mutably from server context") + } + + /// Try to get the headers mut from the server context + pub fn try_response_headers_mut( + &self, + ) -> LockResult> { + self.headers.write() + } + + pub(crate) fn take_response_headers(&self) -> hyper::header::HeaderMap { + let mut headers = self.headers.write().unwrap(); + std::mem::take(&mut *headers) + } + + /// Get the request that triggered: + /// - The initial SSR render if called from a ScopeState or ServerFn + /// - The server function to be called if called from a server function after the initial render + pub fn request_parts(&self) -> &RequestParts { + &self.parts + } + } + + /// Associated parts of an HTTP Request + #[derive(Debug, Default)] + pub struct RequestParts { + /// The request's method + pub method: http::Method, + /// The request's URI + pub uri: http::Uri, + /// The request's version + pub version: http::Version, + /// The request's headers + pub headers: http::HeaderMap, + /// The request's extensions + pub extensions: http::Extensions, + } + + impl From for RequestParts { + fn from(parts: http::request::Parts) -> Self { + Self { + method: parts.method, + uri: parts.uri, + version: parts.version, + headers: parts.headers, + extensions: parts.extensions, + } + } + } +} diff --git a/packages/fullstack/src/server_fn.rs b/packages/fullstack/src/server_fn.rs new file mode 100644 index 000000000..da7311263 --- /dev/null +++ b/packages/fullstack/src/server_fn.rs @@ -0,0 +1,118 @@ +use crate::server_context::DioxusServerContext; + +#[cfg(any(feature = "ssr", doc))] +/// A trait object for a function that be called on serializable arguments and returns a serializable result. +pub type ServerFnTraitObj = server_fn::ServerFnTraitObj; + +#[cfg(any(feature = "ssr", doc))] +/// A server function that can be called on serializable arguments and returns a serializable result. +pub type ServerFunction = server_fn::ServerFunction; + +#[cfg(any(feature = "ssr", doc))] +#[allow(clippy::type_complexity)] +static REGISTERED_SERVER_FUNCTIONS: once_cell::sync::Lazy< + std::sync::Arc>>, +> = once_cell::sync::Lazy::new(Default::default); + +#[cfg(any(feature = "ssr", doc))] +/// The registry of all Dioxus server functions. +pub struct DioxusServerFnRegistry; + +#[cfg(any(feature = "ssr"))] +impl server_fn::ServerFunctionRegistry for DioxusServerFnRegistry { + type Error = ServerRegistrationFnError; + + fn register( + url: &'static str, + server_function: std::sync::Arc, + encoding: server_fn::Encoding, + ) -> Result<(), Self::Error> { + // store it in the hashmap + let mut write = REGISTERED_SERVER_FUNCTIONS + .write() + .map_err(|e| ServerRegistrationFnError::Poisoned(e.to_string()))?; + let prev = write.insert( + url, + ServerFunction { + trait_obj: server_function, + encoding, + }, + ); + + // if there was already a server function with this key, + // return Err + match prev { + Some(_) => Err(ServerRegistrationFnError::AlreadyRegistered(format!( + "There was already a server function registered at {:?}. \ + This can happen if you use the same server function name \ + in two different modules + on `stable` or in `release` mode.", + url + ))), + None => Ok(()), + } + } + + /// Returns the server function registered at the given URL, or `None` if no function is registered at that URL. + fn get(url: &str) -> Option { + REGISTERED_SERVER_FUNCTIONS + .read() + .ok() + .and_then(|fns| fns.get(url).cloned()) + } + + /// Returns the server function registered at the given URL, or `None` if no function is registered at that URL. + fn get_trait_obj(url: &str) -> Option> { + REGISTERED_SERVER_FUNCTIONS + .read() + .ok() + .and_then(|fns| fns.get(url).map(|f| f.trait_obj.clone())) + } + + fn get_encoding(url: &str) -> Option { + REGISTERED_SERVER_FUNCTIONS + .read() + .ok() + .and_then(|fns| fns.get(url).map(|f| f.encoding.clone())) + } + + /// Returns a list of all registered server functions. + fn paths_registered() -> Vec<&'static str> { + REGISTERED_SERVER_FUNCTIONS + .read() + .ok() + .map(|fns| fns.keys().cloned().collect()) + .unwrap_or_default() + } +} + +#[cfg(any(feature = "ssr", doc))] +/// Errors that can occur when registering a server function. +#[derive(thiserror::Error, Debug, Clone, serde::Serialize, serde::Deserialize)] +pub enum ServerRegistrationFnError { + /// The server function is already registered. + #[error("The server function {0} is already registered")] + AlreadyRegistered(String), + /// The server function registry is poisoned. + #[error("The server function registry is poisoned: {0}")] + Poisoned(String), +} + +/// Defines a "server function." A server function can be called from the server or the client, +/// but the body of its code will only be run on the server, i.e., if a crate feature `ssr` is enabled. +/// +/// Server functions are created using the `server` macro. +/// +/// The function should be registered by calling `ServerFn::register()`. The set of server functions +/// can be queried on the server for routing purposes by calling [server_fn::ServerFunctionRegistry::get]. +/// +/// Technically, the trait is implemented on a type that describes the server function's arguments, not the function itself. +pub trait ServerFn: server_fn::ServerFn { + /// Registers the server function, allowing the client to query it by URL. + #[cfg(any(feature = "ssr", doc))] + fn register() -> Result<(), server_fn::ServerFnError> { + Self::register_in::() + } +} + +impl ServerFn for T where T: server_fn::ServerFn {} diff --git a/packages/hooks/Cargo.toml b/packages/hooks/Cargo.toml index c0e1ea783..c7e82c46c 100644 --- a/packages/hooks/Cargo.toml +++ b/packages/hooks/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "dioxus-hooks" -version = "0.3.0" +version = "0.3.1" authors = ["Jonathan Kelley"] edition = "2018" description = "Dioxus VirtualDOM renderer for a remote webview instance" diff --git a/packages/hooks/src/usecoroutine.rs b/packages/hooks/src/usecoroutine.rs index bc97735b9..b19a355d0 100644 --- a/packages/hooks/src/usecoroutine.rs +++ b/packages/hooks/src/usecoroutine.rs @@ -34,6 +34,10 @@ use std::future::Future; /// don't care about actions in your app being synchronized, you can use [`use_callback`] /// hook to spawn multiple tasks and run them concurrently. /// +/// ### Notice +/// In order to use ``rx.next().await``, you will need to extend the ``Stream`` trait (used by ``UnboundedReceiver``) +/// by adding the ``futures-util`` crate as a dependency and adding ``StreamExt`` into scope via ``use futures_util::stream::StreamExt;`` +/// /// ## Example /// /// ```rust, ignore diff --git a/packages/hot-reload/Cargo.toml b/packages/hot-reload/Cargo.toml index 2d88bf029..4809df480 100644 --- a/packages/hot-reload/Cargo.toml +++ b/packages/hot-reload/Cargo.toml @@ -18,7 +18,7 @@ dioxus-html = { path = "../html", features = ["hot-reload-context"], version = " interprocess = { version = "1.2.1" } notify = "5.0.0" -chrono = "0.4.23" +chrono = { version = "0.4.24", default-features = false, features = ["clock"] } serde_json = "1.0.91" serde = { version = "1", features = ["derive"] } execute = "0.2.11" diff --git a/packages/hot-reload/src/lib.rs b/packages/hot-reload/src/lib.rs index d144fccfc..97c15d500 100644 --- a/packages/hot-reload/src/lib.rs +++ b/packages/hot-reload/src/lib.rs @@ -194,7 +194,6 @@ pub fn init(cfg: Config) { println!("Connected to hot reloading 🚀"); } } - std::thread::sleep(std::time::Duration::from_millis(10)); if *aborted.lock().unwrap() { break; } @@ -250,7 +249,7 @@ pub fn init(cfg: Config) { }; for evt in rx { - if chrono::Local::now().timestamp() > last_update_time { + if chrono::Local::now().timestamp_millis() >= last_update_time { if let Ok(evt) = evt { let real_paths = evt .paths @@ -322,7 +321,7 @@ pub fn init(cfg: Config) { } } } - last_update_time = chrono::Local::now().timestamp(); + last_update_time = chrono::Local::now().timestamp_millis(); } } }); diff --git a/packages/html/Cargo.toml b/packages/html/Cargo.toml index 91917ee4b..3d00495ba 100644 --- a/packages/html/Cargo.toml +++ b/packages/html/Cargo.toml @@ -41,6 +41,11 @@ features = [ "FocusEvent", "CompositionEvent", "ClipboardEvent", + "Element", + "DomRect", + "ScrollIntoViewOptions", + "ScrollLogicalPosition", + "ScrollBehavior", ] [dev-dependencies] diff --git a/packages/html/src/elements.rs b/packages/html/src/elements.rs index 3fc400ea9..e1e80057e 100644 --- a/packages/html/src/elements.rs +++ b/packages/html/src/elements.rs @@ -31,16 +31,16 @@ macro_rules! impl_attribute { ( $(#[$attr_method:meta])* - $fil:ident: $vil:ident (in $ns:ident), + $fil:ident: $vil:ident (in $ns:literal), ) => { - pub const $fil: AttributeDiscription = (stringify!($fil), Some(stringify!($ns)), false) + pub const $fil: AttributeDiscription = (stringify!($fil), Some($ns), false) }; ( $(#[$attr_method:meta])* - $fil:ident: $vil:ident (in $ns:ident : volatile), + $fil:ident: $vil:ident (in $ns:literal : volatile), ) => { - pub const $fil: AttributeDiscription = (stringify!($fil), Some(stringify!($ns)), true) + pub const $fil: AttributeDiscription = (stringify!($fil), Some($ns), true) }; } @@ -71,10 +71,10 @@ macro_rules! impl_attribute_match { }; ( - $attr:ident $fil:ident: $vil:ident (in $ns:ident), + $attr:ident $fil:ident: $vil:ident (in $ns:literal), ) => { if $attr == stringify!($fil) { - return Some((stringify!(fil), Some(stringify!(ns)))); + return Some((stringify!(fil), Some(ns))); } }; } @@ -110,7 +110,7 @@ macro_rules! impl_element { ( $(#[$attr:meta])* - $name:ident $namespace:tt { + $name:ident $namespace:literal { $( $(#[$attr_method:meta])* $fil:ident: $vil:ident $extra:tt, @@ -130,7 +130,35 @@ macro_rules! impl_element { $( impl_attribute!( $(#[$attr_method])* - $fil: $vil in $namespace $extra + $fil: $vil ($extra), + ); + )* + } + }; + + ( + $(#[$attr:meta])* + $element:ident [$name:literal, $namespace:tt] { + $( + $(#[$attr_method:meta])* + $fil:ident: $vil:ident $extra:tt, + )* + } + ) => { + #[allow(non_camel_case_types)] + $(#[$attr])* + pub struct $element; + + impl SvgAttributes for $element {} + + impl $element { + pub const TAG_NAME: &'static str = $name; + pub const NAME_SPACE: Option<&'static str> = Some($namespace); + + $( + impl_attribute!( + $(#[$attr_method])* + $fil: $vil ($extra), ); )* } @@ -192,7 +220,7 @@ macro_rules! impl_element_match_attributes { if $el == stringify!($name) { $( impl_attribute_match!( - $attr $fil: $vil in $namespace $extra + $attr $fil: $vil ($extra), ); )* } @@ -359,6 +387,11 @@ builder_constructors! { /// element. header None {}; + /// Build a + /// [`

`](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/hgroup) + /// element. + hgroup None {}; + /// Build a /// [`

`](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/h1) /// element. @@ -1121,6 +1154,7 @@ builder_constructors! { r#type: InputType "type", // value: String, value: String volatile, + initial_value: String DEFAULT, }; /// Build a @@ -1594,7 +1628,7 @@ builder_constructors! { // /// Build a // /// [``](https://developer.mozilla.org/en-US/docs/Web/SVG/Element/use) // /// element. - // use "http://www.w3.org/2000/svg" {}; - - + r#use ["use", "http://www.w3.org/2000/svg"] { + href: String DEFAULT, + }; } diff --git a/packages/html/src/events.rs b/packages/html/src/events.rs index dbad5a8e6..0423e52cf 100644 --- a/packages/html/src/events.rs +++ b/packages/html/src/events.rs @@ -33,6 +33,7 @@ mod form; mod image; mod keyboard; mod media; +mod mounted; mod mouse; mod pointer; mod scroll; @@ -51,6 +52,7 @@ pub use form::*; pub use image::*; pub use keyboard::*; pub use media::*; +pub use mounted::*; pub use mouse::*; pub use pointer::*; pub use scroll::*; @@ -145,6 +147,7 @@ pub fn event_bubbles(evt: &str) -> bool { "animationiteration" => true, "transitionend" => true, "toggle" => true, + "mounted" => false, _ => true, } } diff --git a/packages/html/src/events/mounted.rs b/packages/html/src/events/mounted.rs new file mode 100644 index 000000000..293b51b9e --- /dev/null +++ b/packages/html/src/events/mounted.rs @@ -0,0 +1,131 @@ +//! Handles quering data from the renderer + +use euclid::Rect; + +use std::{ + any::Any, + fmt::{Display, Formatter}, + future::Future, + pin::Pin, + rc::Rc, +}; + +/// An Element that has been rendered and allows reading and modifying information about it. +/// +/// Different platforms will have different implementations and different levels of support for this trait. Renderers that do not support specific features will return `None` for those queries. +// we can not use async_trait here because it does not create a trait that is object safe +pub trait RenderedElementBacking { + /// Get the renderer specific element for the given id + fn get_raw_element(&self) -> MountedResult<&dyn Any> { + Err(MountedError::NotSupported) + } + + /// Get the bounding rectangle of the element relative to the viewport (this does not include the scroll position) + #[allow(clippy::type_complexity)] + fn get_client_rect(&self) -> Pin>>>> { + Box::pin(async { Err(MountedError::NotSupported) }) + } + + /// Scroll to make the element visible + fn scroll_to( + &self, + _behavior: ScrollBehavior, + ) -> Pin>>> { + Box::pin(async { Err(MountedError::NotSupported) }) + } + + /// Set the focus on the element + fn set_focus(&self, _focus: bool) -> Pin>>> { + Box::pin(async { Err(MountedError::NotSupported) }) + } +} + +impl RenderedElementBacking for () {} + +/// The way that scrolling should be performed +#[cfg_attr(feature = "serialize", derive(serde::Serialize, serde::Deserialize))] +pub enum ScrollBehavior { + /// Scroll to the element immediately + #[cfg_attr(feature = "serialize", serde(rename = "instant"))] + Instant, + /// Scroll to the element smoothly + #[cfg_attr(feature = "serialize", serde(rename = "smooth"))] + Smooth, +} + +/// An Element that has been rendered and allows reading and modifying information about it. +/// +/// Different platforms will have different implementations and different levels of support for this trait. Renderers that do not support specific features will return `None` for those queries. +pub struct MountedData { + inner: Rc, +} + +impl MountedData { + /// Create a new MountedData + pub fn new(registry: impl RenderedElementBacking + 'static) -> Self { + Self { + inner: Rc::new(registry), + } + } + + /// Get the renderer specific element for the given id + pub fn get_raw_element(&self) -> MountedResult<&dyn Any> { + self.inner.get_raw_element() + } + + /// Get the bounding rectangle of the element relative to the viewport (this does not include the scroll position) + pub async fn get_client_rect(&self) -> MountedResult> { + self.inner.get_client_rect().await + } + + /// Scroll to make the element visible + pub fn scroll_to( + &self, + behavior: ScrollBehavior, + ) -> Pin>>> { + self.inner.scroll_to(behavior) + } + + /// Set the focus on the element + pub fn set_focus(&self, focus: bool) -> Pin>>> { + self.inner.set_focus(focus) + } +} + +use dioxus_core::Event; + +pub type MountedEvent = Event; + +impl_event! [ + MountedData; + + /// mounted + onmounted +]; + +/// The MountedResult type for the MountedData +pub type MountedResult = Result; + +#[derive(Debug)] +/// The error type for the MountedData +pub enum MountedError { + /// The renderer does not support the requested operation + NotSupported, + /// The element was not found + OperationFailed(Box), +} + +impl Display for MountedError { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + match self { + MountedError::NotSupported => { + write!(f, "The renderer does not support the requested operation") + } + MountedError::OperationFailed(e) => { + write!(f, "The operation failed: {}", e) + } + } + } +} + +impl std::error::Error for MountedError {} diff --git a/packages/html/src/transit.rs b/packages/html/src/transit.rs index 0b84cbb2f..b0fa0d16f 100644 --- a/packages/html/src/transit.rs +++ b/packages/html/src/transit.rs @@ -115,6 +115,9 @@ fn fun_name( "load" | "error" => Image(de(data)?), + // Mounted + "mounted" => Mounted, + // OtherData => "abort" | "afterprint" | "beforeprint" | "beforeunload" | "hashchange" | "languagechange" | "message" | "offline" | "online" | "pagehide" | "pageshow" | "popstate" | "rejectionhandled" | "storage" | "unhandledrejection" | "unload" | "userproximity" | "vrdisplayactivate" | "vrdisplayblur" | "vrdisplayconnect" | "vrdisplaydeactivate" | "vrdisplaydisconnect" | "vrdisplayfocus" | "vrdisplaypointerrestricted" | "vrdisplaypointerunrestricted" | "vrdisplaypresentchange"; other => { return Err(serde_value::DeserializerError::UnknownVariant( @@ -154,6 +157,7 @@ pub enum EventData { Transition(TransitionData), Toggle(ToggleData), Image(ImageData), + Mounted, } impl EventData { @@ -176,6 +180,7 @@ impl EventData { EventData::Transition(data) => Rc::new(data) as Rc, EventData::Toggle(data) => Rc::new(data) as Rc, EventData::Image(data) => Rc::new(data) as Rc, + EventData::Mounted => Rc::new(MountedData::new(())) as Rc, } } } diff --git a/packages/html/src/web_sys_bind/events.rs b/packages/html/src/web_sys_bind/events.rs index 196099444..bcbe80dda 100644 --- a/packages/html/src/web_sys_bind/events.rs +++ b/packages/html/src/web_sys_bind/events.rs @@ -4,14 +4,18 @@ use crate::events::{ }; use crate::geometry::{ClientPoint, Coordinates, ElementPoint, PagePoint, ScreenPoint}; use crate::input_data::{decode_key_location, decode_mouse_button_set, MouseButton}; -use crate::DragData; +use crate::{ + DragData, MountedData, MountedError, MountedResult, RenderedElementBacking, ScrollBehavior, +}; use keyboard_types::{Code, Key, Modifiers}; use std::convert::TryInto; +use std::future::Future; +use std::pin::Pin; use std::str::FromStr; -use wasm_bindgen::JsCast; +use wasm_bindgen::{JsCast, JsValue}; use web_sys::{ - AnimationEvent, CompositionEvent, Event, KeyboardEvent, MouseEvent, PointerEvent, TouchEvent, - TransitionEvent, WheelEvent, + AnimationEvent, CompositionEvent, Event, KeyboardEvent, MouseEvent, PointerEvent, + ScrollIntoViewOptions, TouchEvent, TransitionEvent, WheelEvent, }; macro_rules! uncheck_convert { @@ -193,3 +197,64 @@ impl From<&TransitionEvent> for TransitionData { } } } + +impl From<&web_sys::Element> for MountedData { + fn from(e: &web_sys::Element) -> Self { + MountedData::new(e.clone()) + } +} + +impl RenderedElementBacking for web_sys::Element { + fn get_client_rect( + &self, + ) -> Pin>>>> { + let rect = self.get_bounding_client_rect(); + let result = Ok(euclid::Rect::new( + euclid::Point2D::new(rect.left(), rect.top()), + euclid::Size2D::new(rect.width(), rect.height()), + )); + Box::pin(async { result }) + } + + fn get_raw_element(&self) -> MountedResult<&dyn std::any::Any> { + Ok(self) + } + + fn scroll_to( + &self, + behavior: ScrollBehavior, + ) -> Pin>>> { + match behavior { + ScrollBehavior::Instant => self.scroll_into_view_with_scroll_into_view_options( + ScrollIntoViewOptions::new().behavior(web_sys::ScrollBehavior::Instant), + ), + ScrollBehavior::Smooth => self.scroll_into_view_with_scroll_into_view_options( + ScrollIntoViewOptions::new().behavior(web_sys::ScrollBehavior::Smooth), + ), + } + + Box::pin(async { Ok(()) }) + } + + fn set_focus(&self, focus: bool) -> Pin>>> { + let result = self + .dyn_ref::() + .ok_or_else(|| MountedError::OperationFailed(Box::new(FocusError(self.into())))) + .and_then(|e| { + (if focus { e.focus() } else { e.blur() }) + .map_err(|err| MountedError::OperationFailed(Box::new(FocusError(err)))) + }); + Box::pin(async { result }) + } +} + +#[derive(Debug)] +struct FocusError(JsValue); + +impl std::fmt::Display for FocusError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "failed to focus element {:?}", self.0) + } +} + +impl std::error::Error for FocusError {} diff --git a/packages/interpreter/Cargo.toml b/packages/interpreter/Cargo.toml index 76f9e19ca..b34dad992 100644 --- a/packages/interpreter/Cargo.toml +++ b/packages/interpreter/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "dioxus-interpreter-js" -version = "0.3.1" +version = "0.3.2" edition = "2018" authors = ["Jonathan Kelley"] description = "JS Intepreter for Dioxus - a concurrent renderer-agnostic Virtual DOM for interactive user experiences" @@ -18,8 +18,11 @@ js-sys = { version = "0.3.56", optional = true } web-sys = { version = "0.3.56", optional = true, features = ["Element", "Node"] } sledgehammer_bindgen = { version = "0.2.1", optional = true } sledgehammer_utils = { version = "0.1.1", optional = true } +serde = { version = "1.0", features = ["derive"], optional = true } [features] default = [] +serialize = ["serde"] web = ["wasm-bindgen", "js-sys", "web-sys"] sledgehammer = ["wasm-bindgen", "js-sys", "web-sys", "sledgehammer_bindgen", "sledgehammer_utils"] +minimal_bindings = [] diff --git a/packages/interpreter/src/bindings.rs b/packages/interpreter/src/bindings.rs index 478b8ca5c..4fd9016f1 100644 --- a/packages/interpreter/src/bindings.rs +++ b/packages/interpreter/src/bindings.rs @@ -6,10 +6,14 @@ use web_sys::Element; #[wasm_bindgen(module = "/src/interpreter.js")] extern "C" { + pub type InterpreterConfig; + #[wasm_bindgen(constructor)] + pub fn new(intercept_link_redirects: bool) -> InterpreterConfig; + pub type Interpreter; #[wasm_bindgen(constructor)] - pub fn new(arg: Element) -> Interpreter; + pub fn new(arg: Element, config: InterpreterConfig) -> Interpreter; #[wasm_bindgen(method)] pub fn SaveTemplate(this: &Interpreter, template: JsValue); diff --git a/packages/interpreter/src/common.js b/packages/interpreter/src/common.js new file mode 100644 index 000000000..532c231db --- /dev/null +++ b/packages/interpreter/src/common.js @@ -0,0 +1,68 @@ +const bool_attrs = { + allowfullscreen: true, + allowpaymentrequest: true, + async: true, + autofocus: true, + autoplay: true, + checked: true, + controls: true, + default: true, + defer: true, + disabled: true, + formnovalidate: true, + hidden: true, + ismap: true, + itemscope: true, + loop: true, + multiple: true, + muted: true, + nomodule: true, + novalidate: true, + open: true, + playsinline: true, + readonly: true, + required: true, + reversed: true, + selected: true, + truespeed: true, +}; + +export function setAttributeInner(node, field, value, ns) { + const name = field; + if (ns === "style") { + // ????? why do we need to do this + if (node.style === undefined) { + node.style = {}; + } + node.style[name] = value; + } else if (ns != null && ns != undefined) { + node.setAttributeNS(ns, name, value); + } else { + switch (name) { + case "value": + if (value !== node.value) { + node.value = value; + } + break; + case "initial_value": + node.defaultValue = value; + break; + case "checked": + node.checked = value === "true" || value === true; + break; + case "selected": + node.selected = value === "true" || value === true; + break; + case "dangerous_inner_html": + node.innerHTML = value; + break; + default: + // https://github.com/facebook/react/blob/8b88ac2592c5f555f315f9440cbb665dd1e7457a/packages/react-dom/src/shared/DOMProperty.js#L352-L364 + if (value === "false" && bool_attrs.hasOwnProperty(name)) { + node.removeAttribute(name); + } else { + node.setAttribute(name, value); + } + } + } +} \ No newline at end of file diff --git a/packages/interpreter/src/interpreter.js b/packages/interpreter/src/interpreter.js index 185976725..9b450e30a 100644 --- a/packages/interpreter/src/interpreter.js +++ b/packages/interpreter/src/interpreter.js @@ -1,3 +1,5 @@ +import { setAttributeInner } from "./common.js"; + class ListenerMap { constructor(root) { // bubbling events can listen at the root element @@ -53,8 +55,15 @@ class ListenerMap { } } +class InterpreterConfig { + constructor(intercept_link_redirects) { + this.intercept_link_redirects = intercept_link_redirects; + } +} + class Interpreter { - constructor(root) { + constructor(root, config) { + this.config = config; this.root = root; this.listeners = new ListenerMap(root); this.nodes = [root]; @@ -146,43 +155,7 @@ class Interpreter { this.RemoveAttribute(id, field, ns); } else { const node = this.nodes[id]; - this.SetAttributeInner(node, field, value, ns); - } - } - SetAttributeInner(node, field, value, ns) { - const name = field; - if (ns === "style") { - // ????? why do we need to do this - if (node.style === undefined) { - node.style = {}; - } - node.style[name] = value; - } else if (ns != null && ns != undefined) { - node.setAttributeNS(ns, name, value); - } else { - switch (name) { - case "value": - if (value !== node.value) { - node.value = value; - } - break; - case "checked": - node.checked = value === "true" || value === true; - break; - case "selected": - node.selected = value === "true" || value === true; - break; - case "dangerous_inner_html": - node.innerHTML = value; - break; - default: - // https://github.com/facebook/react/blob/8b88ac2592c5f555f315f9440cbb665dd1e7457a/packages/react-dom/src/shared/DOMProperty.js#L352-L364 - if (value === "false" && bool_attrs.hasOwnProperty(name)) { - node.removeAttribute(name); - } else { - node.setAttribute(name, value); - } - } + setAttributeInner(node, field, value, ns); } } RemoveAttribute(root, field, ns) { @@ -204,6 +177,45 @@ class Interpreter { node.removeAttribute(name); } } + + GetClientRect(id) { + const node = this.nodes[id]; + if (!node) { + return; + } + const rect = node.getBoundingClientRect(); + return { + type: "GetClientRect", + origin: [rect.x, rect.y], + size: [rect.width, rect.height], + }; + } + + ScrollTo(id, behavior) { + const node = this.nodes[id]; + if (!node) { + return false; + } + node.scrollIntoView({ + behavior: behavior, + }); + return true; + } + + /// Set the focus on the element + SetFocus(id, focus) { + const node = this.nodes[id]; + if (!node) { + return false; + } + if (focus) { + node.focus(); + } else { + node.blur(); + } + return true; + } + handleEdits(edits) { for (let template of edits.templates) { this.SaveTemplate(template); @@ -245,7 +257,7 @@ class Interpreter { for (let attr of node.attrs) { if (attr.type == "Static") { - this.SetAttributeInner(el, attr.name, attr.value, attr.namespace); + setAttributeInner(el, attr.name, attr.value, attr.namespace); } } @@ -346,9 +358,21 @@ class Interpreter { case "NewEventListener": let bubbles = event_bubbles(edit.name); - this.NewEventListener(edit.name, edit.id, bubbles, (event) => { - handler(event, edit.name, bubbles); - }); + // if this is a mounted listener, we send the event immediately + if (edit.name === "mounted") { + window.ipc.postMessage( + serializeIpcMessage("user_event", { + name: edit.name, + element: edit.id, + data: null, + bubbles, + }) + ); + } else { + this.NewEventListener(edit.name, edit.id, bubbles, (event) => { + handler(event, edit.name, bubbles, this.config); + }); + } break; } } @@ -356,32 +380,34 @@ class Interpreter { // this handler is only provided on the desktop and liveview implementations since this // method is not used by the web implementation -function handler(event, name, bubbles) { +function handler(event, name, bubbles, config) { let target = event.target; if (target != null) { let preventDefaultRequests = target.getAttribute(`dioxus-prevent-default`); if (event.type === "click") { // todo call prevent default if it's the right type of event - let a_element = target.closest("a"); - if (a_element != null) { - event.preventDefault(); + if (config.intercept_link_redirects) { + let a_element = target.closest("a"); + if (a_element != null) { + event.preventDefault(); - let elementShouldPreventDefault = - preventDefaultRequests && preventDefaultRequests.includes(`onclick`); - let aElementShouldPreventDefault = a_element.getAttribute( - `dioxus-prevent-default` - ); - let linkShouldPreventDefault = - aElementShouldPreventDefault && - aElementShouldPreventDefault.includes(`onclick`); + let elementShouldPreventDefault = + preventDefaultRequests && preventDefaultRequests.includes(`onclick`); + let aElementShouldPreventDefault = a_element.getAttribute( + `dioxus-prevent-default` + ); + let linkShouldPreventDefault = + aElementShouldPreventDefault && + aElementShouldPreventDefault.includes(`onclick`); - if (!elementShouldPreventDefault && !linkShouldPreventDefault) { - const href = a_element.getAttribute("href"); - if (href !== "" && href !== null && href !== undefined) { - window.ipc.postMessage( - serializeIpcMessage("browser_open", { href }) - ); + if (!elementShouldPreventDefault && !linkShouldPreventDefault) { + const href = a_element.getAttribute("href"); + if (href !== "" && href !== null && href !== undefined) { + window.ipc.postMessage( + serializeIpcMessage("browser_open", { href }) + ); + } } } } @@ -734,34 +760,6 @@ function serialize_event(event) { function serializeIpcMessage(method, params = {}) { return JSON.stringify({ method, params }); } -const bool_attrs = { - allowfullscreen: true, - allowpaymentrequest: true, - async: true, - autofocus: true, - autoplay: true, - checked: true, - controls: true, - default: true, - defer: true, - disabled: true, - formnovalidate: true, - hidden: true, - ismap: true, - itemscope: true, - loop: true, - multiple: true, - muted: true, - nomodule: true, - novalidate: true, - open: true, - playsinline: true, - readonly: true, - required: true, - reversed: true, - selected: true, - truespeed: true, -}; function is_element_node(node) { return node.nodeType == 1; @@ -932,6 +930,8 @@ function event_bubbles(event) { return true; case "toggle": return true; + case "mounted": + return false; } return true; diff --git a/packages/interpreter/src/lib.rs b/packages/interpreter/src/lib.rs index 962bd6bbb..47093c29c 100644 --- a/packages/interpreter/src/lib.rs +++ b/packages/interpreter/src/lib.rs @@ -1,4 +1,5 @@ pub static INTERPRETER_JS: &str = include_str!("./interpreter.js"); +pub static COMMON_JS: &str = include_str!("./common.js"); #[cfg(feature = "sledgehammer")] mod sledgehammer_bindings; @@ -10,3 +11,13 @@ mod bindings; #[cfg(feature = "web")] pub use bindings::Interpreter; + +// Common bindings for minimal usage. +#[cfg(feature = "minimal_bindings")] +pub mod minimal_bindings { + use wasm_bindgen::{prelude::wasm_bindgen, JsValue}; + #[wasm_bindgen(module = "/src/common.js")] + extern "C" { + pub fn setAttributeInner(node: JsValue, name: &str, value: JsValue, ns: Option<&str>); + } +} diff --git a/packages/interpreter/src/sledgehammer_bindings.rs b/packages/interpreter/src/sledgehammer_bindings.rs index b1b82823c..6c2804b59 100644 --- a/packages/interpreter/src/sledgehammer_bindings.rs +++ b/packages/interpreter/src/sledgehammer_bindings.rs @@ -74,6 +74,9 @@ mod js { node.value = value; } break; + case "initial_value": + node.defaultValue = value; + break; case "checked": node.checked = value === "true"; break; @@ -117,6 +120,9 @@ mod js { export function set_node(id, node) { nodes[id] = node; } + export function get_node(id) { + return nodes[id]; + } export function initilize(root, handler) { listeners.handler = handler; nodes = [root]; @@ -167,6 +173,9 @@ mod js { #[wasm_bindgen] pub fn set_node(id: u32, node: Node); + #[wasm_bindgen] + pub fn get_node(id: u32) -> Node; + #[wasm_bindgen] pub fn initilize(root: Node, handler: &Function); } diff --git a/packages/liveview/Cargo.toml b/packages/liveview/Cargo.toml index 6a7ad6aee..498d92c89 100644 --- a/packages/liveview/Cargo.toml +++ b/packages/liveview/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "dioxus-liveview" -version = "0.3.0" +version = "0.3.1" edition = "2021" repository = "https://github.com/DioxusLabs/dioxus/" homepage = "https://dioxuslabs.com" @@ -14,6 +14,8 @@ license = "MIT/Apache-2.0" [dependencies] thiserror = "1.0.38" +log = "0.4.14" +slab = "0.4" futures-util = { version = "0.3.25", default-features = false, features = [ "sink", ] } @@ -44,7 +46,7 @@ once_cell = "1.17.1" # actix-ws = { version = "0.2.5", optional = true } [dev-dependencies] -pretty_env_logger = { version = "0.4.0" } +pretty_env_logger = { version = "0.5.0" } tokio = { version = "1.22.0", features = ["full"] } dioxus = { path = "../dioxus", version = "0.3.0" } warp = "0.3.3" diff --git a/packages/liveview/src/element.rs b/packages/liveview/src/element.rs new file mode 100644 index 000000000..21be23bee --- /dev/null +++ b/packages/liveview/src/element.rs @@ -0,0 +1,125 @@ +use dioxus_core::ElementId; +use dioxus_html::{geometry::euclid::Rect, MountedResult, RenderedElementBacking}; +use tokio::sync::mpsc::UnboundedSender; + +use crate::query::QueryEngine; + +/// A mounted element passed to onmounted events +pub struct LiveviewElement { + id: ElementId, + query_tx: UnboundedSender, + query: QueryEngine, +} + +impl LiveviewElement { + pub(crate) fn new(id: ElementId, tx: UnboundedSender, query: QueryEngine) -> Self { + Self { + id, + query_tx: tx, + query, + } + } +} + +impl RenderedElementBacking for LiveviewElement { + fn get_raw_element(&self) -> dioxus_html::MountedResult<&dyn std::any::Any> { + Ok(self) + } + + fn get_client_rect( + &self, + ) -> std::pin::Pin< + Box< + dyn futures_util::Future< + Output = dioxus_html::MountedResult>, + >, + >, + > { + let script = format!("return window.interpreter.GetClientRect({});", self.id.0); + + let fut = self + .query + .new_query::>>(&script, &self.query_tx) + .resolve(); + Box::pin(async move { + match fut.await { + Ok(Some(rect)) => Ok(rect), + Ok(None) => MountedResult::Err(dioxus_html::MountedError::OperationFailed( + Box::new(DesktopQueryError::FailedToQuery), + )), + Err(err) => { + MountedResult::Err(dioxus_html::MountedError::OperationFailed(Box::new(err))) + } + } + }) + } + + fn scroll_to( + &self, + behavior: dioxus_html::ScrollBehavior, + ) -> std::pin::Pin>>> { + let script = format!( + "return window.interpreter.ScrollTo({}, {});", + self.id.0, + serde_json::to_string(&behavior).expect("Failed to serialize ScrollBehavior") + ); + + let fut = self + .query + .new_query::(&script, &self.query_tx) + .resolve(); + Box::pin(async move { + match fut.await { + Ok(true) => Ok(()), + Ok(false) => MountedResult::Err(dioxus_html::MountedError::OperationFailed( + Box::new(DesktopQueryError::FailedToQuery), + )), + Err(err) => { + MountedResult::Err(dioxus_html::MountedError::OperationFailed(Box::new(err))) + } + } + }) + } + + fn set_focus( + &self, + focus: bool, + ) -> std::pin::Pin>>> { + let script = format!( + "return window.interpreter.SetFocus({}, {});", + self.id.0, focus + ); + + let fut = self + .query + .new_query::(&script, &self.query_tx) + .resolve(); + + Box::pin(async move { + match fut.await { + Ok(true) => Ok(()), + Ok(false) => MountedResult::Err(dioxus_html::MountedError::OperationFailed( + Box::new(DesktopQueryError::FailedToQuery), + )), + Err(err) => { + MountedResult::Err(dioxus_html::MountedError::OperationFailed(Box::new(err))) + } + } + }) + } +} + +#[derive(Debug)] +enum DesktopQueryError { + FailedToQuery, +} + +impl std::fmt::Display for DesktopQueryError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + DesktopQueryError::FailedToQuery => write!(f, "Failed to query the element"), + } + } +} + +impl std::error::Error for DesktopQueryError {} diff --git a/packages/liveview/src/lib.rs b/packages/liveview/src/lib.rs index bea21a71b..ff02febac 100644 --- a/packages/liveview/src/lib.rs +++ b/packages/liveview/src/lib.rs @@ -18,7 +18,9 @@ pub mod adapters { pub use adapters::*; +mod element; pub mod pool; +mod query; use futures_util::{SinkExt, StreamExt}; pub use pool::*; @@ -76,7 +78,13 @@ static INTERPRETER_JS: Lazy = Lazy::new(|| { } }"#; - interpreter.replace("/*POST_EVENT_SERIALIZATION*/", serialize_file_uploads) + let interpreter = interpreter.replace("/*POST_EVENT_SERIALIZATION*/", serialize_file_uploads); + interpreter.replace("import { setAttributeInner } from \"./common.js\";", "") +}); + +static COMMON_JS: Lazy = Lazy::new(|| { + let common = dioxus_interpreter_js::COMMON_JS; + common.replace("export", "") }); static MAIN_JS: &str = include_str!("./main.js"); @@ -87,11 +95,13 @@ static MAIN_JS: &str = include_str!("./main.js"); /// processing user events and returning edits to the liveview instance pub fn interpreter_glue(url: &str) -> String { let js = &*INTERPRETER_JS; + let common = &*COMMON_JS; format!( r#" diff --git a/packages/liveview/src/main.js b/packages/liveview/src/main.js index 7dd9758d6..e98d02a9c 100644 --- a/packages/liveview/src/main.js +++ b/packages/liveview/src/main.js @@ -8,7 +8,7 @@ function main() { class IPC { constructor(root) { // connect to the websocket - window.interpreter = new Interpreter(root); + window.interpreter = new Interpreter(root, new InterpreterConfig(false)); let ws = new WebSocket(WS_ADDR); @@ -26,11 +26,19 @@ class IPC { // todo: retry the connection }; - ws.onmessage = (event) => { + ws.onmessage = (message) => { // Ignore pongs - if (event.data != "__pong__") { - let edits = JSON.parse(event.data); - window.interpreter.handleEdits(edits); + if (message.data != "__pong__") { + const event = JSON.parse(message.data); + switch (event.type) { + case "edits": + let edits = event.data; + window.interpreter.handleEdits(edits); + break; + case "query": + Function("Eval", `"use strict";${event.data};`)(); + break; + } } }; diff --git a/packages/liveview/src/pool.rs b/packages/liveview/src/pool.rs index e02c8195b..089d0bad4 100644 --- a/packages/liveview/src/pool.rs +++ b/packages/liveview/src/pool.rs @@ -1,8 +1,13 @@ -use crate::LiveViewError; -use dioxus_core::prelude::*; -use dioxus_html::HtmlEvent; +use crate::{ + element::LiveviewElement, + query::{QueryEngine, QueryResult}, + LiveViewError, +}; +use dioxus_core::{prelude::*, Mutations}; +use dioxus_html::{EventData, HtmlEvent, MountedData}; use futures_util::{pin_mut, SinkExt, StreamExt}; -use std::time::Duration; +use serde::Serialize; +use std::{rc::Rc, time::Duration}; use tokio_util::task::LocalPoolHandle; #[derive(Clone)] @@ -115,7 +120,7 @@ pub async fn run(mut vdom: VirtualDom, ws: impl LiveViewSocket) -> Result<(), Li }; // todo: use an efficient binary packed format for this - let edits = serde_json::to_string(&vdom.rebuild()).unwrap(); + let edits = serde_json::to_string(&ClientUpdate::Edits(vdom.rebuild())).unwrap(); // pin the futures so we can use select! pin_mut!(ws); @@ -123,11 +128,19 @@ pub async fn run(mut vdom: VirtualDom, ws: impl LiveViewSocket) -> Result<(), Li // send the initial render to the client ws.send(edits).await?; + // Create the a proxy for query engine + let (query_tx, mut query_rx) = tokio::sync::mpsc::unbounded_channel(); + let query_engine = QueryEngine::default(); + // desktop uses this wrapper struct thing around the actual event itself // this is sorta driven by tao/wry - #[derive(serde::Deserialize)] - struct IpcMessage { - params: HtmlEvent, + #[derive(serde::Deserialize, Debug)] + #[serde(tag = "method", content = "params")] + enum IpcMessage { + #[serde(rename = "user_event")] + Event(HtmlEvent), + #[serde(rename = "query")] + Query(QueryResult), } loop { @@ -147,16 +160,45 @@ pub async fn run(mut vdom: VirtualDom, ws: impl LiveViewSocket) -> Result<(), Li ws.send("__pong__".to_string()).await?; } Some(Ok(evt)) => { - if let Ok(IpcMessage { params }) = serde_json::from_str::(evt) { - vdom.handle_event(¶ms.name, params.data.into_any(), params.element, params.bubbles); + if let Ok(message) = serde_json::from_str::(evt) { + match message { + IpcMessage::Event(evt) => { + // Intercept the mounted event and insert a custom element type + if let EventData::Mounted = &evt.data { + let element = LiveviewElement::new(evt.element, query_tx.clone(), query_engine.clone()); + vdom.handle_event( + &evt.name, + Rc::new(MountedData::new(element)), + evt.element, + evt.bubbles, + ); + } + else{ + vdom.handle_event( + &evt.name, + evt.data.into_any(), + evt.element, + evt.bubbles, + ); + } + } + IpcMessage::Query(result) => { + query_engine.send(result); + }, + } } } // log this I guess? when would we get an error here? - Some(Err(_e)) => {}, + Some(Err(_e)) => {} None => return Ok(()), } } + // handle any new queries + Some(query) = query_rx.recv() => { + ws.send(serde_json::to_string(&ClientUpdate::Query(query)).unwrap()).await?; + } + Some(msg) = hot_reload_wait => { #[cfg(all(feature = "hot-reload", debug_assertions))] match msg{ @@ -176,6 +218,16 @@ pub async fn run(mut vdom: VirtualDom, ws: impl LiveViewSocket) -> Result<(), Li .render_with_deadline(tokio::time::sleep(Duration::from_millis(10))) .await; - ws.send(serde_json::to_string(&edits).unwrap()).await?; + ws.send(serde_json::to_string(&ClientUpdate::Edits(edits)).unwrap()) + .await?; } } + +#[derive(Serialize)] +#[serde(tag = "type", content = "data")] +enum ClientUpdate<'a> { + #[serde(rename = "edits")] + Edits(Mutations<'a>), + #[serde(rename = "query")] + Query(String), +} diff --git a/packages/liveview/src/query.rs b/packages/liveview/src/query.rs new file mode 100644 index 000000000..04d531a63 --- /dev/null +++ b/packages/liveview/src/query.rs @@ -0,0 +1,113 @@ +use std::{cell::RefCell, rc::Rc}; + +use serde::{de::DeserializeOwned, Deserialize}; +use serde_json::Value; +use slab::Slab; +use thiserror::Error; +use tokio::sync::{broadcast::error::RecvError, mpsc::UnboundedSender}; + +/// Tracks what query ids are currently active +#[derive(Default, Clone)] +struct SharedSlab { + slab: Rc>>, +} + +/// Handles sending and receiving arbitrary queries from the webview. Queries can be resolved non-sequentially, so we use ids to track them. +#[derive(Clone)] +pub(crate) struct QueryEngine { + sender: Rc>, + active_requests: SharedSlab, +} + +impl Default for QueryEngine { + fn default() -> Self { + let (sender, _) = tokio::sync::broadcast::channel(8); + Self { + sender: Rc::new(sender), + active_requests: SharedSlab::default(), + } + } +} + +impl QueryEngine { + /// Creates a new query and returns a handle to it. The query will be resolved when the webview returns a result with the same id. + pub fn new_query( + &self, + script: &str, + tx: &UnboundedSender, + ) -> Query { + let request_id = self.active_requests.slab.borrow_mut().insert(()); + + // start the query + // We embed the return of the eval in a function so we can send it back to the main thread + if let Err(err) = tx.send(format!( + r#"window.ipc.postMessage( + JSON.stringify({{ + "method":"query", + "params": {{ + "id": {request_id}, + "data": (function(){{{script}}})() + }} + }}) + );"# + )) { + log::warn!("Query error: {err}"); + } + + Query { + slab: self.active_requests.clone(), + id: request_id, + reciever: self.sender.subscribe(), + phantom: std::marker::PhantomData, + } + } + + /// Send a query result + pub fn send(&self, data: QueryResult) { + let _ = self.sender.send(data); + } +} + +pub(crate) struct Query { + slab: SharedSlab, + id: usize, + reciever: tokio::sync::broadcast::Receiver, + phantom: std::marker::PhantomData, +} + +impl Query { + /// Resolve the query + pub async fn resolve(mut self) -> Result { + let result = loop { + match self.reciever.recv().await { + Ok(result) => { + if result.id == self.id { + break V::deserialize(result.data).map_err(QueryError::DeserializeError); + } + } + Err(err) => { + break Err(QueryError::RecvError(err)); + } + } + }; + + // Remove the query from the slab + self.slab.slab.borrow_mut().remove(self.id); + + result + } +} + +#[derive(Error, Debug)] +pub enum QueryError { + #[error("Error receiving query result: {0}")] + RecvError(RecvError), + #[error("Error deserializing query result: {0}")] + DeserializeError(serde_json::Error), +} + +#[derive(Clone, Debug, Deserialize)] +pub(crate) struct QueryResult { + id: usize, + data: Value, +} diff --git a/packages/native-core/Cargo.toml b/packages/native-core/Cargo.toml index c9be2f27d..7832a33ae 100644 --- a/packages/native-core/Cargo.toml +++ b/packages/native-core/Cargo.toml @@ -14,7 +14,7 @@ keywords = ["dom", "ui", "gui", "react"] dioxus-core = { path = "../core", version = "^0.3.0", optional = true } keyboard-types = "0.6.2" -taffy = "0.2.1" +taffy = "0.3.12" smallvec = "1.6" rustc-hash = "1.1.0" anymap = "1.0.0-beta.2" diff --git a/packages/native-core/src/layout_attributes.rs b/packages/native-core/src/layout_attributes.rs index 84fd61ac4..4eb39292b 100644 --- a/packages/native-core/src/layout_attributes.rs +++ b/packages/native-core/src/layout_attributes.rs @@ -2,7 +2,7 @@ /* - [ ] pub display: Display, ----> taffy doesnt support all display types -- [x] pub position_type: PositionType, --> taffy doesnt support everything +- [x] pub position: Position, --> taffy doesnt support everything - [x] pub direction: Direction, - [x] pub flex_direction: FlexDirection, @@ -11,6 +11,14 @@ - [x] pub flex_shrink: f32, - [x] pub flex_basis: Dimension, +- [x]pub grid_auto_flow: GridAutoFlow, +- [x]pub grid_template_rows: GridTrackVec, +- [x]pub grid_template_columns: GridTrackVec, +- [x]pub grid_auto_rows: GridTrackVec, +- [x]pub grid_auto_columns: GridTrackVec, +- [x]pub grid_row: Line, +- [x]pub grid_column: Line, + - [x] pub overflow: Overflow, ---> taffy doesnt have support for directional overflow - [x] pub align_items: AlignItems, @@ -21,20 +29,22 @@ - [x] pub padding: Rect, - [x] pub justify_content: JustifyContent, -- [x] pub position: Rect, +- [x] pub inset: Rect, - [x] pub border: Rect, - [ ] pub size: Size, ----> seems to only be relevant for input? - [ ] pub min_size: Size, - [ ] pub max_size: Size, -- [ ] pub aspect_ratio: Number, ----> parsing is done, but taffy doesnt support it +- [x] pub aspect_ratio: Number, */ use lightningcss::properties::border::LineStyle; -use lightningcss::properties::{align, display, flex, position, size}; +use lightningcss::properties::grid::{TrackBreadth, TrackSizing}; +use lightningcss::properties::{align, border, display, flex, grid, position, size}; +use lightningcss::values::percentage::Percentage; use lightningcss::{ - properties::{align::GapValue, border::BorderSideWidth, Property, PropertyId}, + properties::{Property, PropertyId}, stylesheet::ParserOptions, traits::Parse, values::{ @@ -45,7 +55,7 @@ use lightningcss::{ }; use taffy::{ prelude::*, - style::{FlexDirection, PositionType}, + style::{FlexDirection, Position}, }; /// Default values for layout attributes @@ -95,33 +105,35 @@ pub fn apply_layout_attributes_cfg( display::Display::Keyword(display::DisplayKeyword::None) => { style.display = Display::None } - display::Display::Pair(pair) => { - if let display::DisplayInside::Flex(_) = pair.inside { - style.display = Display::Flex + display::Display::Pair(pair) => match pair.inside { + display::DisplayInside::Flex(_) => { + style.display = Display::Flex; } - } - _ => (), + display::DisplayInside::Grid => { + style.display = Display::Grid; + } + _ => {} + }, + _ => {} }, Property::Position(position) => { - style.position_type = match position { - position::Position::Relative => PositionType::Relative, - position::Position::Absolute => PositionType::Absolute, + style.position = match position { + position::Position::Relative => Position::Relative, + position::Position::Absolute => Position::Absolute, _ => return, } } - Property::Top(top) => style.position.top = convert_length_percentage_or_auto(top), + Property::Top(top) => style.inset.top = convert_length_percentage_or_auto(top), Property::Bottom(bottom) => { - style.position.bottom = convert_length_percentage_or_auto(bottom) - } - Property::Left(left) => style.position.left = convert_length_percentage_or_auto(left), - Property::Right(right) => { - style.position.right = convert_length_percentage_or_auto(right) + style.inset.bottom = convert_length_percentage_or_auto(bottom) } + Property::Left(left) => style.inset.left = convert_length_percentage_or_auto(left), + Property::Right(right) => style.inset.right = convert_length_percentage_or_auto(right), Property::Inset(inset) => { - style.position.top = convert_length_percentage_or_auto(inset.top); - style.position.bottom = convert_length_percentage_or_auto(inset.bottom); - style.position.left = convert_length_percentage_or_auto(inset.left); - style.position.right = convert_length_percentage_or_auto(inset.right); + style.inset.top = convert_length_percentage_or_auto(inset.top); + style.inset.bottom = convert_length_percentage_or_auto(inset.bottom); + style.inset.left = convert_length_percentage_or_auto(inset.left); + style.inset.right = convert_length_percentage_or_auto(inset.right); } Property::BorderTopWidth(width) => { style.border.top = convert_border_side_width(width, &config.border_widths); @@ -164,46 +176,64 @@ pub fn apply_layout_attributes_cfg( } Property::BorderTopStyle(line_style) => { if line_style != LineStyle::None { - style.border.top = - convert_border_side_width(BorderSideWidth::Medium, &config.border_widths); + style.border.top = convert_border_side_width( + border::BorderSideWidth::Medium, + &config.border_widths, + ); } } Property::BorderBottomStyle(line_style) => { if line_style != LineStyle::None { - style.border.bottom = - convert_border_side_width(BorderSideWidth::Medium, &config.border_widths); + style.border.bottom = convert_border_side_width( + border::BorderSideWidth::Medium, + &config.border_widths, + ); } } Property::BorderLeftStyle(line_style) => { if line_style != LineStyle::None { - style.border.left = - convert_border_side_width(BorderSideWidth::Medium, &config.border_widths); + style.border.left = convert_border_side_width( + border::BorderSideWidth::Medium, + &config.border_widths, + ); } } Property::BorderRightStyle(line_style) => { if line_style != LineStyle::None { - style.border.right = - convert_border_side_width(BorderSideWidth::Medium, &config.border_widths); + style.border.right = convert_border_side_width( + border::BorderSideWidth::Medium, + &config.border_widths, + ); } } Property::BorderStyle(styles) => { if styles.top != LineStyle::None { - style.border.top = - convert_border_side_width(BorderSideWidth::Medium, &config.border_widths); + style.border.top = convert_border_side_width( + border::BorderSideWidth::Medium, + &config.border_widths, + ); } if styles.bottom != LineStyle::None { - style.border.bottom = - convert_border_side_width(BorderSideWidth::Medium, &config.border_widths); + style.border.bottom = convert_border_side_width( + border::BorderSideWidth::Medium, + &config.border_widths, + ); } if styles.left != LineStyle::None { - style.border.left = - convert_border_side_width(BorderSideWidth::Medium, &config.border_widths); + style.border.left = convert_border_side_width( + border::BorderSideWidth::Medium, + &config.border_widths, + ); } if styles.right != LineStyle::None { - style.border.right = - convert_border_side_width(BorderSideWidth::Medium, &config.border_widths); + style.border.right = convert_border_side_width( + border::BorderSideWidth::Medium, + &config.border_widths, + ); } } + + // Flexbox properties Property::FlexDirection(flex_direction, _) => { use FlexDirection::*; style.flex_direction = match flex_direction { @@ -228,75 +258,123 @@ pub fn apply_layout_attributes_cfg( style.flex_shrink = shrink; } Property::FlexBasis(basis, _) => { - style.flex_basis = convert_length_percentage_or_auto(basis); + style.flex_basis = convert_length_percentage_or_auto(basis).into(); } Property::Flex(flex, _) => { style.flex_grow = flex.grow; style.flex_shrink = flex.shrink; - style.flex_basis = convert_length_percentage_or_auto(flex.basis); + style.flex_basis = convert_length_percentage_or_auto(flex.basis).into(); } + + // Grid properties + Property::GridAutoFlow(grid_auto_flow) => { + let is_row = grid_auto_flow.contains(grid::GridAutoFlow::Row); + let is_dense = grid_auto_flow.contains(grid::GridAutoFlow::Dense); + style.grid_auto_flow = match (is_row, is_dense) { + (true, false) => GridAutoFlow::Row, + (false, false) => GridAutoFlow::Column, + (true, true) => GridAutoFlow::RowDense, + (false, true) => GridAutoFlow::ColumnDense, + }; + } + Property::GridTemplateColumns(TrackSizing::TrackList(track_list)) => { + style.grid_template_columns = track_list + .items + .into_iter() + .map(convert_grid_track_item) + .collect(); + } + Property::GridTemplateRows(TrackSizing::TrackList(track_list)) => { + style.grid_template_rows = track_list + .items + .into_iter() + .map(convert_grid_track_item) + .collect(); + } + Property::GridAutoColumns(grid::TrackSizeList(track_size_list)) => { + style.grid_auto_columns = track_size_list + .into_iter() + .map(convert_grid_track_size) + .collect(); + } + Property::GridAutoRows(grid::TrackSizeList(track_size_list)) => { + style.grid_auto_rows = track_size_list + .into_iter() + .map(convert_grid_track_size) + .collect(); + } + Property::GridRow(grid_row) => { + style.grid_row = Line { + start: convert_grid_placement(grid_row.start), + end: convert_grid_placement(grid_row.end), + }; + } + Property::GridColumn(grid_column) => { + style.grid_column = Line { + start: convert_grid_placement(grid_column.start), + end: convert_grid_placement(grid_column.end), + }; + } + + // Alignment properties Property::AlignContent(align, _) => { use AlignContent::*; style.align_content = match align { align::AlignContent::ContentDistribution(distribution) => match distribution { - align::ContentDistribution::SpaceBetween => SpaceBetween, - align::ContentDistribution::SpaceAround => SpaceAround, - align::ContentDistribution::SpaceEvenly => SpaceEvenly, - align::ContentDistribution::Stretch => Stretch, + align::ContentDistribution::SpaceBetween => Some(SpaceBetween), + align::ContentDistribution::SpaceAround => Some(SpaceAround), + align::ContentDistribution::SpaceEvenly => Some(SpaceEvenly), + align::ContentDistribution::Stretch => Some(Stretch), }, align::AlignContent::ContentPosition { value: position, .. } => match position { - align::ContentPosition::Center => Center, - align::ContentPosition::Start | align::ContentPosition::FlexStart => { - FlexStart - } - align::ContentPosition::End | align::ContentPosition::FlexEnd => FlexEnd, + align::ContentPosition::Center => Some(Center), + align::ContentPosition::Start => Some(Start), + align::ContentPosition::FlexStart => Some(FlexStart), + align::ContentPosition::End => Some(End), + align::ContentPosition::FlexEnd => Some(FlexEnd), }, _ => return, }; } Property::JustifyContent(justify, _) => { - use JustifyContent::*; + use AlignContent::*; style.justify_content = match justify { align::JustifyContent::ContentDistribution(distribution) => { match distribution { - align::ContentDistribution::SpaceBetween => SpaceBetween, - align::ContentDistribution::SpaceAround => SpaceAround, - align::ContentDistribution::SpaceEvenly => SpaceEvenly, + align::ContentDistribution::SpaceBetween => Some(SpaceBetween), + align::ContentDistribution::SpaceAround => Some(SpaceAround), + align::ContentDistribution::SpaceEvenly => Some(SpaceEvenly), _ => return, } } align::JustifyContent::ContentPosition { value: position, .. } => match position { - align::ContentPosition::Center => Center, - // start ignores -reverse flex-direction but there is no way to specify that in Taffy - align::ContentPosition::Start | align::ContentPosition::FlexStart => { - FlexStart - } - // end ignores -reverse flex-direction but there is no way to specify that in Taffy - align::ContentPosition::End | align::ContentPosition::FlexEnd => FlexEnd, + align::ContentPosition::Center => Some(Center), + align::ContentPosition::Start => Some(Start), + align::ContentPosition::FlexStart => Some(FlexStart), + align::ContentPosition::End => Some(End), + align::ContentPosition::FlexEnd => Some(FlexEnd), }, _ => return, }; } Property::AlignSelf(align, _) => { - use AlignSelf::*; + use AlignItems::*; style.align_self = match align { - align::AlignSelf::Auto => Auto, - align::AlignSelf::Stretch => Stretch, - align::AlignSelf::BaselinePosition(_) => Baseline, + align::AlignSelf::Auto => None, + align::AlignSelf::Stretch => Some(Stretch), + align::AlignSelf::BaselinePosition(_) => Some(Baseline), align::AlignSelf::SelfPosition { value: position, .. } => match position { - align::SelfPosition::Center => Center, - align::SelfPosition::Start - | align::SelfPosition::SelfStart - | align::SelfPosition::FlexStart => FlexStart, - align::SelfPosition::End - | align::SelfPosition::SelfEnd - | align::SelfPosition::FlexEnd => FlexEnd, + align::SelfPosition::Center => Some(Center), + align::SelfPosition::Start | align::SelfPosition::SelfStart => Some(Start), + align::SelfPosition::FlexStart => Some(FlexStart), + align::SelfPosition::End | align::SelfPosition::SelfEnd => Some(End), + align::SelfPosition::FlexEnd => Some(FlexEnd), }, _ => return, }; @@ -304,15 +382,18 @@ pub fn apply_layout_attributes_cfg( Property::AlignItems(align, _) => { use AlignItems::*; style.align_items = match align { - align::AlignItems::BaselinePosition(_) => Baseline, - align::AlignItems::Stretch => Stretch, + align::AlignItems::BaselinePosition(_) => Some(Baseline), + align::AlignItems::Stretch => Some(Stretch), align::AlignItems::SelfPosition { value: position, .. } => match position { - align::SelfPosition::Center => Center, - align::SelfPosition::FlexStart => FlexStart, - align::SelfPosition::FlexEnd => FlexEnd, - _ => return, + align::SelfPosition::Center => Some(Center), + align::SelfPosition::FlexStart => Some(FlexStart), + align::SelfPosition::FlexEnd => Some(FlexEnd), + align::SelfPosition::Start | align::SelfPosition::SelfStart => { + Some(FlexEnd) + } + align::SelfPosition::End | align::SelfPosition::SelfEnd => Some(FlexEnd), }, _ => return, }; @@ -350,23 +431,23 @@ pub fn apply_layout_attributes_cfg( }; } Property::PaddingTop(padding) => { - style.padding.top = convert_length_percentage_or_auto(padding); + style.padding.top = convert_padding(padding); } Property::PaddingBottom(padding) => { - style.padding.bottom = convert_length_percentage_or_auto(padding); + style.padding.bottom = convert_padding(padding); } Property::PaddingLeft(padding) => { - style.padding.left = convert_length_percentage_or_auto(padding); + style.padding.left = convert_padding(padding); } Property::PaddingRight(padding) => { - style.padding.right = convert_length_percentage_or_auto(padding); + style.padding.right = convert_padding(padding); } Property::Padding(padding) => { style.padding = Rect { - top: convert_length_percentage_or_auto(padding.top), - bottom: convert_length_percentage_or_auto(padding.bottom), - left: convert_length_percentage_or_auto(padding.left), - right: convert_length_percentage_or_auto(padding.right), + top: convert_padding(padding.top), + bottom: convert_padding(padding.bottom), + left: convert_padding(padding.left), + right: convert_padding(padding.right), }; } Property::Width(width) => { @@ -386,59 +467,157 @@ pub fn apply_layout_attributes_cfg( } } -fn convert_length_value(length_value: LengthValue) -> Dimension { +fn extract_px_value(length_value: LengthValue) -> f32 { match length_value { - LengthValue::Px(value) => Dimension::Points(value), + LengthValue::Px(value) => value, _ => todo!(), } } -fn convert_dimension_percentage( +fn convert_length_percentage( dimension_percentage: DimensionPercentage, -) -> Dimension { +) -> LengthPercentage { match dimension_percentage { - DimensionPercentage::Dimension(value) => convert_length_value(value), - DimensionPercentage::Percentage(percentage) => Dimension::Percent(percentage.0), - _ => todo!(), + DimensionPercentage::Dimension(value) => LengthPercentage::Points(extract_px_value(value)), + DimensionPercentage::Percentage(percentage) => LengthPercentage::Percent(percentage.0), + DimensionPercentage::Calc(_) => todo!(), + } +} + +fn convert_padding(dimension_percentage: LengthPercentageOrAuto) -> LengthPercentage { + match dimension_percentage { + LengthPercentageOrAuto::Auto => unimplemented!(), + LengthPercentageOrAuto::LengthPercentage(lp) => match lp { + DimensionPercentage::Dimension(value) => { + LengthPercentage::Points(extract_px_value(value)) + } + DimensionPercentage::Percentage(percentage) => LengthPercentage::Percent(percentage.0), + DimensionPercentage::Calc(_) => unimplemented!(), + }, } } fn convert_length_percentage_or_auto( - length_percentage_or_auto: LengthPercentageOrAuto, -) -> Dimension { - match length_percentage_or_auto { - LengthPercentageOrAuto::Auto => Dimension::Auto, - LengthPercentageOrAuto::LengthPercentage(percentage) => { - convert_dimension_percentage(percentage) - } + dimension_percentage: LengthPercentageOrAuto, +) -> LengthPercentageAuto { + match dimension_percentage { + LengthPercentageOrAuto::Auto => LengthPercentageAuto::Auto, + LengthPercentageOrAuto::LengthPercentage(lp) => match lp { + DimensionPercentage::Dimension(value) => { + LengthPercentageAuto::Points(extract_px_value(value)) + } + DimensionPercentage::Percentage(percentage) => { + LengthPercentageAuto::Percent(percentage.0) + } + DimensionPercentage::Calc(_) => todo!(), + }, + } +} + +fn convert_dimension(dimension_percentage: DimensionPercentage) -> Dimension { + match dimension_percentage { + DimensionPercentage::Dimension(value) => Dimension::Points(extract_px_value(value)), + DimensionPercentage::Percentage(percentage) => Dimension::Percent(percentage.0), + DimensionPercentage::Calc(_) => todo!(), } } fn convert_border_side_width( - border_side_width: BorderSideWidth, + border_side_width: border::BorderSideWidth, border_width_config: &BorderWidths, -) -> Dimension { +) -> LengthPercentage { match border_side_width { - BorderSideWidth::Length(Length::Value(value)) => convert_length_value(value), - BorderSideWidth::Thick => Dimension::Points(border_width_config.thick), - BorderSideWidth::Medium => Dimension::Points(border_width_config.medium), - BorderSideWidth::Thin => Dimension::Points(border_width_config.thin), - _ => todo!(), + border::BorderSideWidth::Length(Length::Value(value)) => { + LengthPercentage::Points(extract_px_value(value)) + } + border::BorderSideWidth::Thick => LengthPercentage::Points(border_width_config.thick), + border::BorderSideWidth::Medium => LengthPercentage::Points(border_width_config.medium), + border::BorderSideWidth::Thin => LengthPercentage::Points(border_width_config.thin), + border::BorderSideWidth::Length(_) => unimplemented!(), } } -fn convert_gap_value(gap_value: GapValue) -> Dimension { +fn convert_gap_value(gap_value: align::GapValue) -> LengthPercentage { match gap_value { - GapValue::LengthPercentage(dim) => convert_dimension_percentage(dim), - GapValue::Normal => Dimension::Auto, + align::GapValue::LengthPercentage(dim) => convert_length_percentage(dim), + align::GapValue::Normal => LengthPercentage::Points(0.0), } } fn convert_size(size: size::Size) -> Dimension { match size { size::Size::Auto => Dimension::Auto, - size::Size::LengthPercentage(length) => convert_dimension_percentage(length), - _ => todo!(), + size::Size::LengthPercentage(length) => convert_dimension(length), + size::Size::MinContent(_) => Dimension::Auto, // Unimplemented, so default auto + size::Size::MaxContent(_) => Dimension::Auto, // Unimplemented, so default auto + size::Size::FitContent(_) => Dimension::Auto, // Unimplemented, so default auto + size::Size::FitContentFunction(_) => Dimension::Auto, // Unimplemented, so default auto + size::Size::Stretch(_) => Dimension::Auto, // Unimplemented, so default auto + size::Size::Contain => Dimension::Auto, // Unimplemented, so default auto + } +} + +fn convert_grid_placement(input: grid::GridLine) -> GridPlacement { + match input { + grid::GridLine::Auto => GridPlacement::Auto, + grid::GridLine::Line { index, .. } => line(index as i16), + grid::GridLine::Span { index, .. } => span(index as u16), + grid::GridLine::Area { .. } => unimplemented!(), + } +} + +fn convert_grid_track_item(input: grid::TrackListItem) -> TrackSizingFunction { + match input { + grid::TrackListItem::TrackSize(size) => { + TrackSizingFunction::Single(convert_grid_track_size(size)) + } + grid::TrackListItem::TrackRepeat(_) => todo!(), // TODO: requires TrackRepeat fields to be public! + } +} + +fn convert_grid_track_size(input: grid::TrackSize) -> NonRepeatedTrackSizingFunction { + match input { + grid::TrackSize::TrackBreadth(breadth) => minmax( + convert_track_breadth_min(&breadth), + convert_track_breadth_max(&breadth), + ), + grid::TrackSize::MinMax { min, max } => minmax( + convert_track_breadth_min(&min), + convert_track_breadth_max(&max), + ), + grid::TrackSize::FitContent(limit) => match limit { + DimensionPercentage::Dimension(LengthValue::Px(len)) => minmax(auto(), points(len)), + DimensionPercentage::Percentage(Percentage(pct)) => minmax(auto(), percent(pct)), + _ => unimplemented!(), + }, + } +} + +fn convert_track_breadth_max(breadth: &TrackBreadth) -> MaxTrackSizingFunction { + match breadth { + grid::TrackBreadth::Length(length_percentage) => match length_percentage { + DimensionPercentage::Dimension(LengthValue::Px(len)) => points(*len), + DimensionPercentage::Percentage(Percentage(pct)) => percent(*pct), + _ => unimplemented!(), + }, + grid::TrackBreadth::Flex(fraction) => fr(*fraction), + grid::TrackBreadth::MinContent => MaxTrackSizingFunction::MinContent, + grid::TrackBreadth::MaxContent => MaxTrackSizingFunction::MaxContent, + grid::TrackBreadth::Auto => MaxTrackSizingFunction::Auto, + } +} + +fn convert_track_breadth_min(breadth: &TrackBreadth) -> MinTrackSizingFunction { + match breadth { + grid::TrackBreadth::Length(length_percentage) => match length_percentage { + DimensionPercentage::Dimension(LengthValue::Px(len)) => points(*len), + DimensionPercentage::Percentage(Percentage(pct)) => percent(*pct), + _ => unimplemented!(), + }, + grid::TrackBreadth::MinContent => MinTrackSizingFunction::MinContent, + grid::TrackBreadth::MaxContent => MinTrackSizingFunction::MaxContent, + grid::TrackBreadth::Auto => MinTrackSizingFunction::Auto, + grid::TrackBreadth::Flex(_) => MinTrackSizingFunction::Auto, } } diff --git a/packages/rink/Cargo.toml b/packages/rink/Cargo.toml index 2f08eb720..3e19048dd 100644 --- a/packages/rink/Cargo.toml +++ b/packages/rink/Cargo.toml @@ -22,7 +22,7 @@ crossterm = "0.23.0" anyhow = "1.0.42" tokio = { version = "1.15.0", features = ["full"] } futures = "0.3.19" -taffy = "0.2.1" +taffy = "0.3.12" smallvec = "1.6" rustc-hash = "1.1.0" anymap = "1.0.0-beta.2" diff --git a/packages/rink/src/layout.rs b/packages/rink/src/layout.rs index b7eff57a6..944d39400 100644 --- a/packages/rink/src/layout.rs +++ b/packages/rink/src/layout.rs @@ -81,10 +81,11 @@ impl State for TaffyLayout { }; if let PossiblyUninitalized::Initialized(n) = self.node { if self.style != style { - taffy.set_style(n, style).unwrap(); + taffy.set_style(n, style.clone()).unwrap(); } } else { - self.node = PossiblyUninitalized::Initialized(taffy.new_leaf(style).unwrap()); + self.node = + PossiblyUninitalized::Initialized(taffy.new_leaf(style.clone()).unwrap()); changed = true; } } else { @@ -117,69 +118,90 @@ impl State for TaffyLayout { child_layout.push(l.node.unwrap()); } - fn scale_dimention(d: Dimension) -> Dimension { + fn scale_dimension(d: Dimension) -> Dimension { match d { Dimension::Points(p) => Dimension::Points(unit_to_layout_space(p)), Dimension::Percent(p) => Dimension::Percent(p), Dimension::Auto => Dimension::Auto, - Dimension::Undefined => Dimension::Undefined, } } - let style = Style { - position: Rect { - left: scale_dimention(style.position.left), - right: scale_dimention(style.position.right), - top: scale_dimention(style.position.top), - bottom: scale_dimention(style.position.bottom), + + fn scale_length_percentage_auto(d: LengthPercentageAuto) -> LengthPercentageAuto { + match d { + LengthPercentageAuto::Points(p) => { + LengthPercentageAuto::Points(unit_to_layout_space(p)) + } + LengthPercentageAuto::Percent(p) => LengthPercentageAuto::Percent(p), + LengthPercentageAuto::Auto => LengthPercentageAuto::Auto, + } + } + + fn scale_length_percentage(d: LengthPercentage) -> LengthPercentage { + match d { + LengthPercentage::Points(p) => { + LengthPercentage::Points(unit_to_layout_space(p)) + } + LengthPercentage::Percent(p) => LengthPercentage::Percent(p), + } + } + + let scaled_style = Style { + inset: Rect { + left: scale_length_percentage_auto(style.inset.left), + right: scale_length_percentage_auto(style.inset.right), + top: scale_length_percentage_auto(style.inset.top), + bottom: scale_length_percentage_auto(style.inset.bottom), }, margin: Rect { - left: scale_dimention(style.margin.left), - right: scale_dimention(style.margin.right), - top: scale_dimention(style.margin.top), - bottom: scale_dimention(style.margin.bottom), + left: scale_length_percentage_auto(style.margin.left), + right: scale_length_percentage_auto(style.margin.right), + top: scale_length_percentage_auto(style.margin.top), + bottom: scale_length_percentage_auto(style.margin.bottom), }, padding: Rect { - left: scale_dimention(style.padding.left), - right: scale_dimention(style.padding.right), - top: scale_dimention(style.padding.top), - bottom: scale_dimention(style.padding.bottom), + left: scale_length_percentage(style.padding.left), + right: scale_length_percentage(style.padding.right), + top: scale_length_percentage(style.padding.top), + bottom: scale_length_percentage(style.padding.bottom), }, border: Rect { - left: scale_dimention(style.border.left), - right: scale_dimention(style.border.right), - top: scale_dimention(style.border.top), - bottom: scale_dimention(style.border.bottom), + left: scale_length_percentage(style.border.left), + right: scale_length_percentage(style.border.right), + top: scale_length_percentage(style.border.top), + bottom: scale_length_percentage(style.border.bottom), }, gap: Size { - width: scale_dimention(style.gap.width), - height: scale_dimention(style.gap.height), + width: scale_length_percentage(style.gap.width), + height: scale_length_percentage(style.gap.height), }, - flex_basis: scale_dimention(style.flex_basis), + flex_basis: scale_dimension(style.flex_basis), size: Size { - width: scale_dimention(style.size.width), - height: scale_dimention(style.size.height), + width: scale_dimension(style.size.width), + height: scale_dimension(style.size.height), }, min_size: Size { - width: scale_dimention(style.min_size.width), - height: scale_dimention(style.min_size.height), + width: scale_dimension(style.min_size.width), + height: scale_dimension(style.min_size.height), }, max_size: Size { - width: scale_dimention(style.max_size.width), - height: scale_dimention(style.max_size.height), + width: scale_dimension(style.max_size.width), + height: scale_dimension(style.max_size.height), }, - ..style + ..style.clone() }; if let PossiblyUninitalized::Initialized(n) = self.node { if self.style != style { - taffy.set_style(n, style).unwrap(); + taffy.set_style(n, scaled_style).unwrap(); } if taffy.children(n).unwrap() != child_layout { taffy.set_children(n, &child_layout).unwrap(); } } else { self.node = PossiblyUninitalized::Initialized( - taffy.new_with_children(style, &child_layout).unwrap(), + taffy + .new_with_children(scaled_style, &child_layout) + .unwrap(), ); changed = true; } diff --git a/packages/rink/src/lib.rs b/packages/rink/src/lib.rs index 7f15aa751..8ab53f833 100644 --- a/packages/rink/src/lib.rs +++ b/packages/rink/src/lib.rs @@ -172,7 +172,7 @@ pub fn render( .unwrap(); // the root node fills the entire area - let mut style = *taffy.style(root_node).unwrap(); + let mut style = taffy.style(root_node).unwrap().clone(); let new_size = Size { width: Dimension::Points(width), height: Dimension::Points(height), diff --git a/packages/rink/src/query.rs b/packages/rink/src/query.rs index 1cf113310..2f4fe02e0 100644 --- a/packages/rink/src/query.rs +++ b/packages/rink/src/query.rs @@ -91,15 +91,7 @@ impl<'a> ElementRef<'a> { pub fn layout(&self) -> Option { let layout = self .stretch - .layout( - self.inner - .get(self.id) - .unwrap() - .get::() - .unwrap() - .node - .ok()?, - ) + .layout(self.inner.get(self.id)?.get::()?.node.ok()?) .ok(); layout.map(|layout| Layout { order: layout.order, diff --git a/packages/router/src/components/router.rs b/packages/router/src/components/router.rs index 54131a13a..8d5365c35 100644 --- a/packages/router/src/components/router.rs +++ b/packages/router/src/components/router.rs @@ -29,7 +29,9 @@ pub struct RouterProps<'a> { pub active_class: Option<&'a str>, /// Set the initial url. - pub initial_url: Option, + // This is Option> because we want to be able to either omit the prop or pass in Option + #[props(into)] + pub initial_url: Option>, } /// A component that conditionally renders children based on the current location of the app. @@ -45,7 +47,7 @@ pub fn Router<'a>(cx: Scope<'a, RouterProps<'a>>) -> Element { RouterCfg { base_url: cx.props.base_url.map(|s| s.to_string()), active_class: cx.props.active_class.map(|s| s.to_string()), - initial_url: cx.props.initial_url.clone(), + initial_url: cx.props.initial_url.clone().flatten(), }, )) }); diff --git a/packages/rsx-rosetta/src/lib.rs b/packages/rsx-rosetta/src/lib.rs index 64fed1e18..4f30c0081 100644 --- a/packages/rsx-rosetta/src/lib.rs +++ b/packages/rsx-rosetta/src/lib.rs @@ -1,6 +1,6 @@ use convert_case::{Case, Casing}; use dioxus_rsx::{ - BodyNode, CallBody, Component, Element, ElementAttr, ElementAttrNamed, IfmtInput, + BodyNode, CallBody, Component, Element, ElementAttr, ElementAttrNamed, ElementName, IfmtInput, }; pub use html_parser::{Dom, Node}; use proc_macro2::{Ident, Span}; @@ -21,7 +21,7 @@ pub fn rsx_node_from_html(node: &Node) -> Option { Node::Text(text) => Some(BodyNode::Text(ifmt_from_text(text))), Node::Element(el) => { let el_name = el.name.to_case(Case::Snake); - let el_name = Ident::new(el_name.as_str(), Span::call_site()); + let el_name = ElementName::Ident(Ident::new(el_name.as_str(), Span::call_site())); let mut attributes: Vec<_> = el .attributes diff --git a/packages/rsx/src/element.rs b/packages/rsx/src/element.rs index e47c2e02a..466200eca 100644 --- a/packages/rsx/src/element.rs +++ b/packages/rsx/src/element.rs @@ -1,9 +1,13 @@ +use std::fmt::{Display, Formatter}; + use super::*; use proc_macro2::{Span, TokenStream as TokenStream2}; use quote::{quote, ToTokens, TokenStreamExt}; use syn::{ parse::{Parse, ParseBuffer, ParseStream}, + punctuated::Punctuated, + spanned::Spanned, Error, Expr, Ident, LitStr, Result, Token, }; @@ -12,7 +16,7 @@ use syn::{ // ======================================= #[derive(PartialEq, Eq, Clone, Debug, Hash)] pub struct Element { - pub name: Ident, + pub name: ElementName, pub key: Option, pub attributes: Vec, pub children: Vec, @@ -22,7 +26,7 @@ pub struct Element { impl Parse for Element { fn parse(stream: ParseStream) -> Result { - let el_name = Ident::parse(stream)?; + let el_name = ElementName::parse(stream)?; // parse the guts let content: ParseBuffer; @@ -181,7 +185,7 @@ impl ToTokens for Element { tokens.append_all(quote! { __cx.element( - dioxus_elements::#name, + #name, __cx.bump().alloc([ #(#listeners),* ]), __cx.bump().alloc([ #(#attr),* ]), __cx.bump().alloc([ #(#children),* ]), @@ -191,6 +195,75 @@ impl ToTokens for Element { } } +#[derive(PartialEq, Eq, Clone, Debug, Hash)] +pub enum ElementName { + Ident(Ident), + Custom(LitStr), +} + +impl ElementName { + pub(crate) fn tag_name(&self) -> TokenStream2 { + match self { + ElementName::Ident(i) => quote! { dioxus_elements::#i::TAG_NAME }, + ElementName::Custom(s) => quote! { #s }, + } + } +} + +impl ElementName { + pub fn span(&self) -> Span { + match self { + ElementName::Ident(i) => i.span(), + ElementName::Custom(s) => s.span(), + } + } +} + +impl PartialEq<&str> for ElementName { + fn eq(&self, other: &&str) -> bool { + match self { + ElementName::Ident(i) => i == *other, + ElementName::Custom(s) => s.value() == *other, + } + } +} + +impl Display for ElementName { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + match self { + ElementName::Ident(i) => write!(f, "{}", i), + ElementName::Custom(s) => write!(f, "{}", s.value()), + } + } +} + +impl Parse for ElementName { + fn parse(stream: ParseStream) -> Result { + let raw = Punctuated::::parse_separated_nonempty(stream)?; + if raw.len() == 1 { + Ok(ElementName::Ident(raw.into_iter().next().unwrap())) + } else { + let span = raw.span(); + let tag = raw + .into_iter() + .map(|ident| ident.to_string()) + .collect::>() + .join("-"); + let tag = LitStr::new(&tag, span); + Ok(ElementName::Custom(tag)) + } + } +} + +impl ToTokens for ElementName { + fn to_tokens(&self, tokens: &mut TokenStream2) { + match self { + ElementName::Ident(i) => tokens.append_all(quote! { dioxus_elements::#i }), + ElementName::Custom(s) => tokens.append_all(quote! { #s }), + } + } +} + #[derive(PartialEq, Eq, Clone, Debug, Hash)] pub enum ElementAttr { /// `attribute: "value"` @@ -234,7 +307,7 @@ impl ElementAttr { #[derive(PartialEq, Eq, Clone, Debug, Hash)] pub struct ElementAttrNamed { - pub el_name: Ident, + pub el_name: ElementName, pub attr: ElementAttr, } @@ -242,24 +315,46 @@ impl ToTokens for ElementAttrNamed { fn to_tokens(&self, tokens: &mut TokenStream2) { let ElementAttrNamed { el_name, attr } = self; - tokens.append_all(match attr { + let ns = |name| match el_name { + ElementName::Ident(i) => quote! { dioxus_elements::#i::#name.1 }, + ElementName::Custom(_) => quote! { None }, + }; + let volitile = |name| match el_name { + ElementName::Ident(_) => quote! { #el_name::#name.2 }, + ElementName::Custom(_) => quote! { false }, + }; + let attribute = |name: &Ident| match el_name { + ElementName::Ident(_) => quote! { #el_name::#name.0 }, + ElementName::Custom(_) => { + let as_string = name.to_string(); + quote!(#as_string) + } + }; + + let attribute = match attr { ElementAttr::AttrText { name, value } => { + let ns = ns(name); + let volitile = volitile(name); + let attribute = attribute(name); quote! { __cx.attr( - dioxus_elements::#el_name::#name.0, + #attribute, #value, - dioxus_elements::#el_name::#name.1, - dioxus_elements::#el_name::#name.2 + #ns, + #volitile ) } } ElementAttr::AttrExpression { name, value } => { + let ns = ns(name); + let volitile = volitile(name); + let attribute = attribute(name); quote! { __cx.attr( - dioxus_elements::#el_name::#name.0, + #attribute, #value, - dioxus_elements::#el_name::#name.1, - dioxus_elements::#el_name::#name.2 + #ns, + #volitile ) } } @@ -288,7 +383,9 @@ impl ToTokens for ElementAttrNamed { dioxus_elements::events::#name(__cx, #tokens) } } - }); + }; + + tokens.append_all(attribute); } } diff --git a/packages/rsx/src/lib.rs b/packages/rsx/src/lib.rs index d22b122e9..07c887e65 100644 --- a/packages/rsx/src/lib.rs +++ b/packages/rsx/src/lib.rs @@ -426,13 +426,25 @@ impl<'a> DynamicContext<'a> { match root { BodyNode::Element(el) => { let el_name = &el.name; + let ns = |name| match el_name { + ElementName::Ident(i) => quote! { dioxus_elements::#i::#name }, + ElementName::Custom(_) => quote! { None }, + }; let static_attrs = el.attributes.iter().map(|attr| match &attr.attr { ElementAttr::AttrText { name, value } if value.is_static() => { let value = value.to_static().unwrap(); + let ns = ns(quote!(#name.1)); + let name = match el_name { + ElementName::Ident(_) => quote! { #el_name::#name.0 }, + ElementName::Custom(_) => { + let as_string = name.to_string(); + quote! { #as_string } + } + }; quote! { ::dioxus::core::TemplateAttribute::Static { - name: dioxus_elements::#el_name::#name.0, - namespace: dioxus_elements::#el_name::#name.1, + name: #name, + namespace: #ns, value: #value, // todo: we don't diff these so we never apply the volatile flag @@ -479,10 +491,13 @@ impl<'a> DynamicContext<'a> { let _opt = el.children.len() == 1; let children = quote! { #(#children),* }; + let ns = ns(quote!(NAME_SPACE)); + let el_name = el_name.tag_name(); + quote! { ::dioxus::core::TemplateNode::Element { - tag: dioxus_elements::#el_name::TAG_NAME, - namespace: dioxus_elements::#el_name::NAME_SPACE, + tag: #el_name, + namespace: #ns, attrs: &[ #attrs ], children: &[ #children ], } diff --git a/packages/rsx/src/node.rs b/packages/rsx/src/node.rs index 0d5556552..54448c883 100644 --- a/packages/rsx/src/node.rs +++ b/packages/rsx/src/node.rs @@ -50,7 +50,16 @@ impl Parse for BodyNode { return Ok(BodyNode::Text(stream.parse()?)); } + // if this is a dash-separated path, it's a web component (custom element) let body_stream = stream.fork(); + if let Ok(ElementName::Custom(name)) = body_stream.parse::() { + if name.value().contains('-') && body_stream.peek(token::Brace) { + return Ok(BodyNode::Element(stream.parse::()?)); + } + } + + let body_stream = stream.fork(); + if let Ok(path) = body_stream.parse::() { // this is an Element if path match of: // - one ident diff --git a/packages/ssr/src/renderer.rs b/packages/ssr/src/renderer.rs index 6f6808c74..a85a3ea7e 100644 --- a/packages/ssr/src/renderer.rs +++ b/packages/ssr/src/renderer.rs @@ -3,7 +3,7 @@ use crate::cache::StringCache; use dioxus_core::{prelude::*, AttributeValue, DynamicNode, RenderReturn}; use std::collections::HashMap; use std::fmt::Write; -use std::rc::Rc; +use std::sync::Arc; /// A virtualdom renderer that caches the templates it has seen for faster rendering #[derive(Default)] @@ -25,7 +25,7 @@ pub struct Renderer { pub skip_components: bool, /// A cache of templates that have been rendered - template_cache: HashMap<&'static str, Rc>, + template_cache: HashMap<&'static str, Arc>, } impl Renderer { @@ -67,7 +67,7 @@ impl Renderer { let entry = self .template_cache .entry(template.template.get().name) - .or_insert_with(|| Rc::new(StringCache::from_template(template).unwrap())) + .or_insert_with(|| Arc::new(StringCache::from_template(template).unwrap())) .clone(); let mut inner_html = None; diff --git a/packages/web/Cargo.toml b/packages/web/Cargo.toml index fcbb996bb..2a90ee9a6 100644 --- a/packages/web/Cargo.toml +++ b/packages/web/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "dioxus-web" -version = "0.3.1" +version = "0.3.2" authors = ["Jonathan Kelley"] edition = "2018" description = "Dioxus VirtualDOM renderer for the web browser using websys" @@ -14,7 +14,8 @@ keywords = ["dom", "ui", "gui", "react", "wasm"] dioxus-core = { path = "../core", version = "^0.3.0", features = ["serialize"] } dioxus-html = { path = "../html", version = "^0.3.0", features = ["wasm-bind"] } dioxus-interpreter-js = { path = "../interpreter", version = "^0.3.0", features = [ - "sledgehammer" + "sledgehammer", + "minimal_bindings", ] } js-sys = "0.3.56" diff --git a/packages/web/src/dom.rs b/packages/web/src/dom.rs index 55c5c8f9f..31edff49f 100644 --- a/packages/web/src/dom.rs +++ b/packages/web/src/dom.rs @@ -10,14 +10,14 @@ use dioxus_core::{ BorrowedAttributeValue, ElementId, Mutation, Template, TemplateAttribute, TemplateNode, }; -use dioxus_html::{event_bubbles, CompositionData, FileEngine, FormData}; -use dioxus_interpreter_js::{save_template, Channel}; +use dioxus_html::{event_bubbles, CompositionData, FileEngine, FormData, MountedData}; +use dioxus_interpreter_js::{get_node, minimal_bindings, save_template, Channel}; use futures_channel::mpsc; use js_sys::Array; use rustc_hash::FxHashMap; use std::{any::Any, rc::Rc, sync::Arc}; -use wasm_bindgen::{closure::Closure, prelude::wasm_bindgen, JsCast}; use web_sys::{Document, Element, Event, HtmlElement}; +use wasm_bindgen::{closure::Closure, prelude::wasm_bindgen, JsCast, JsValue}; use crate::{file_engine::WebFileEngine, Config}; @@ -28,6 +28,7 @@ pub struct WebsysDom { templates: FxHashMap, max_template_id: u32, pub(crate) interpreter: Channel, + event_channel: mpsc::UnboundedSender, } pub struct UiEvent { @@ -35,7 +36,6 @@ pub struct UiEvent { pub bubbles: bool, pub element: ElementId, pub data: Rc, - pub event: Event, } impl WebsysDom { @@ -49,8 +49,9 @@ impl WebsysDom { }; let interpreter = Channel::default(); - let handler: Closure = - Closure::wrap(Box::new(move |event: &web_sys::Event| { + let handler: Closure = Closure::wrap(Box::new({ + let event_channel = event_channel.clone(); + move |event: &web_sys::Event| { let name = event.type_(); let element = walk_event_for_id(event); let bubbles = dioxus_html::event_bubbles(name.as_str()); @@ -74,10 +75,10 @@ impl WebsysDom { bubbles, element, data, - event: event.clone(), }); } - })); + } + })); dioxus_interpreter_js::initilize( root.clone().unchecked_into(), @@ -90,6 +91,7 @@ impl WebsysDom { interpreter, templates: FxHashMap::default(), max_template_id: 0, + event_channel, } } @@ -133,14 +135,12 @@ impl WebsysDom { namespace, } = attr { - match namespace { - Some(ns) if *ns == "style" => { - el.dyn_ref::() - .map(|f| f.style().set_property(name, value)); - } - Some(ns) => el.set_attribute_ns(Some(ns), name, value).unwrap(), - None => el.set_attribute(name, value).unwrap(), - } + minimal_bindings::setAttributeInner( + el.clone().into(), + name, + JsValue::from_str(value), + *namespace, + ); } } for child in *children { @@ -161,6 +161,8 @@ impl WebsysDom { pub fn apply_edits(&mut self, mut edits: Vec) { use Mutation::*; let i = &mut self.interpreter; + // we need to apply the mount events last, so we collect them here + let mut to_mount = Vec::new(); for edit in &edits { match edit { AppendChildren { id, m } => i.append_children(id.0 as u32, *m as u32), @@ -211,17 +213,43 @@ impl WebsysDom { }, SetText { value, id } => i.set_text(id.0 as u32, value), NewEventListener { name, id, .. } => { - i.new_event_listener(name, id.0 as u32, event_bubbles(name) as u8); - } - RemoveEventListener { name, id } => { - i.remove_event_listener(name, id.0 as u32, event_bubbles(name) as u8) + match *name { + // mounted events are fired immediately after the element is mounted. + "mounted" => { + to_mount.push(*id); + } + _ => { + i.new_event_listener(name, id.0 as u32, event_bubbles(name) as u8); + } + } } + RemoveEventListener { name, id } => match *name { + "mounted" => {} + _ => { + i.remove_event_listener(name, id.0 as u32, event_bubbles(name) as u8); + } + }, Remove { id } => i.remove(id.0 as u32), PushRoot { id } => i.push_root(id.0 as u32), } } edits.clear(); i.flush(); + + for id in to_mount { + let node = get_node(id.0 as u32); + if let Some(element) = node.dyn_ref::() { + log::info!("mounted event fired: {}", id.0); + let data: MountedData = element.into(); + let data = Rc::new(data); + let _ = self.event_channel.unbounded_send(UiEvent { + name: "mounted".to_string(), + bubbles: false, + element: id, + data, + }); + } + } } } @@ -380,22 +408,38 @@ extern "C" { } fn walk_event_for_id(event: &web_sys::Event) -> Option<(ElementId, web_sys::Element)> { - let mut target = event + let target = event .target() .expect("missing target") - .dyn_into::() - .expect("not a valid element"); + .dyn_into::() + .expect("not a valid node"); + let mut current_target_element = target.dyn_ref::().cloned(); loop { - match target.get_attribute("data-dioxus-id").map(|f| f.parse()) { - Some(Ok(id)) => return Some((ElementId(id), target)), - Some(Err(_)) => return None, + match ( + current_target_element + .as_ref() + .and_then(|el| el.get_attribute("data-dioxus-id").map(|f| f.parse())), + current_target_element, + ) { + // This node is an element, and has a dioxus id, so we can stop walking + (Some(Ok(id)), Some(target)) => return Some((ElementId(id), target)), - // walk the tree upwards until we actually find an event target - None => match target.parent_element() { - Some(parent) => target = parent, - None => return None, - }, + // Walk the tree upwards until we actually find an event target + (None, target_element) => { + let parent = match target_element.as_ref() { + Some(el) => el.parent_element(), + // if this is the first node and not an element, we need to get the parent from the target node + None => target.parent_element(), + }; + match parent { + Some(parent) => current_target_element = Some(parent), + _ => return None, + } + } + + // This node is an element with an invalid dioxus id, give up + _ => return None, } } } diff --git a/packages/web/src/lib.rs b/packages/web/src/lib.rs index 34aa7033b..9fec0dcd6 100644 --- a/packages/web/src/lib.rs +++ b/packages/web/src/lib.rs @@ -254,7 +254,7 @@ pub async fn run_with_props(root: fn(Scope) -> Element, root_prop res = rx.try_next().transpose().unwrap().ok(); } - // Todo: This is currently disabled because it has a negative impact on responce times for events but it could be re-enabled for tasks + // Todo: This is currently disabled because it has a negative impact on response times for events but it could be re-enabled for tasks // Jank free rendering // // 1. wait for the browser to give us "idle" time