diff --git a/.github/workflows/main-checks.yml b/.github/workflows/main-checks.yml index 818d33e47da..5c3833a5835 100644 --- a/.github/workflows/main-checks.yml +++ b/.github/workflows/main-checks.yml @@ -25,7 +25,7 @@ jobs: uses: actions-rs/cargo@v1 with: command: clippy - args: --all-targets -- -D warnings + args: --all-targets --all-features -- -D warnings - name: Lint feature soundness run: | @@ -51,12 +51,11 @@ jobs: - uses: Swatinem/rust-cache@v1 - - name: Run clippy uses: actions-rs/cargo@v1 with: command: clippy - args: --all-targets --release -- -D warnings + args: --all-targets --all-features --release -- -D warnings - name: Lint feature soundness run: | @@ -108,7 +107,7 @@ jobs: uses: actions-rs/cargo@v1 with: command: test - args: --doc --workspace --exclude yew --exclude changelog --exclude website-test --target wasm32-unknown-unknown + args: --doc --workspace --exclude yew --exclude changelog --exclude website-test --exclude ssr_router --exclude simple_ssr --target wasm32-unknown-unknown - name: Run website code snippet tests uses: actions-rs/cargo@v1 @@ -196,7 +195,7 @@ jobs: uses: actions-rs/cargo@v1 with: command: test - args: --all-targets --workspace --exclude yew --exclude website-test + args: --all-targets --workspace --exclude yew --exclude website-test --exclude ssr_router --exclude simple_ssr - name: Run native tests for yew uses: actions-rs/cargo@v1 diff --git a/examples/simple_ssr/Cargo.toml b/examples/simple_ssr/Cargo.toml index a9559e37335..0e1fc0aad6d 100644 --- a/examples/simple_ssr/Cargo.toml +++ b/examples/simple_ssr/Cargo.toml @@ -6,7 +6,7 @@ edition = "2021" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html [dependencies] -yew = { path = "../../packages/yew", features = ["ssr", "hydration"] } +yew = { path = "../../packages/yew" } reqwest = { version = "0.11.8", features = ["json"] } serde = { version = "1.0.132", features = ["derive"] } uuid = { version = "1.0.0", features = ["serde"] } @@ -23,3 +23,7 @@ num_cpus = "1.13" tokio-util = { version = "0.7", features = ["rt"] } once_cell = "1.5" clap = { version = "3.1.7", features = ["derive"] } + +[features] +hydration = ["yew/hydration"] +ssr = ["yew/ssr", "yew/tokio"] diff --git a/examples/simple_ssr/README.md b/examples/simple_ssr/README.md index 6c02a63edf3..41105b934dd 100644 --- a/examples/simple_ssr/README.md +++ b/examples/simple_ssr/README.md @@ -10,7 +10,7 @@ This example demonstrates server-side rendering. 2. Run the server -`cargo run --bin simple_ssr_server -- --dir examples/simple_ssr/dist` +`cargo run --features=ssr --bin simple_ssr_server -- --dir examples/simple_ssr/dist` 3. Open Browser diff --git a/examples/simple_ssr/index.html b/examples/simple_ssr/index.html index 62951cf4073..2cb77d4d490 100644 --- a/examples/simple_ssr/index.html +++ b/examples/simple_ssr/index.html @@ -4,6 +4,6 @@ Yew SSR Example - + diff --git a/examples/simple_ssr/src/lib.rs b/examples/simple_ssr/src/lib.rs index 893c2c94ce5..dd291b644a8 100644 --- a/examples/simple_ssr/src/lib.rs +++ b/examples/simple_ssr/src/lib.rs @@ -1,20 +1,13 @@ -use std::cell::RefCell; -use std::rc::Rc; - use serde::{Deserialize, Serialize}; -#[cfg(not(target_arch = "wasm32"))] -use tokio::task::spawn_local; use uuid::Uuid; -#[cfg(target_arch = "wasm32")] -use wasm_bindgen_futures::spawn_local; use yew::prelude::*; -use yew::suspense::{Suspension, SuspensionResult}; #[derive(Serialize, Deserialize)] struct UuidResponse { uuid: Uuid, } +#[cfg(feature = "ssr")] async fn fetch_uuid() -> Uuid { // reqwest works for both non-wasm and wasm targets. let resp = reqwest::get("https://httpbin.org/uuid").await.unwrap(); @@ -23,56 +16,9 @@ async fn fetch_uuid() -> Uuid { uuid_resp.uuid } -pub struct UuidState { - s: Suspension, - value: Rc>>, -} - -impl UuidState { - fn new() -> Self { - let (s, handle) = Suspension::new(); - let value: Rc>> = Rc::default(); - - { - let value = value.clone(); - // we use tokio spawn local here. - spawn_local(async move { - let uuid = fetch_uuid().await; - - { - let mut value = value.borrow_mut(); - *value = Some(uuid); - } - - handle.resume(); - }); - } - - Self { s, value } - } -} - -impl PartialEq for UuidState { - fn eq(&self, rhs: &Self) -> bool { - self.s == rhs.s - } -} - -#[hook] -fn use_random_uuid() -> SuspensionResult { - let s = use_state(UuidState::new); - - let result = match *s.value.borrow() { - Some(ref m) => Ok(*m), - None => Err(s.s.clone()), - }; - - result -} - #[function_component] fn Content() -> HtmlResult { - let uuid = use_random_uuid()?; + let uuid = use_prepared_state!(async |_| -> Uuid { fetch_uuid().await }, ())?.unwrap(); Ok(html! {
{"Random UUID: "}{uuid}
diff --git a/examples/ssr_router/Cargo.toml b/examples/ssr_router/Cargo.toml index 635bd21d19a..6cc7c73fda6 100644 --- a/examples/ssr_router/Cargo.toml +++ b/examples/ssr_router/Cargo.toml @@ -6,7 +6,7 @@ edition = "2021" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html [dependencies] -yew = { path = "../../packages/yew", features = ["ssr", "hydration"] } +yew = { path = "../../packages/yew" } function_router = { path = "../function_router" } log = "0.4" @@ -24,3 +24,7 @@ num_cpus = "1.13" tokio-util = { version = "0.7", features = ["rt"] } once_cell = "1.5" clap = { version = "3.1.7", features = ["derive"] } + +[features] +ssr = ["yew/ssr"] +hydration = ["yew/hydration"] diff --git a/examples/ssr_router/README.md b/examples/ssr_router/README.md index 0f65d5a50b9..013c3b63c4a 100644 --- a/examples/ssr_router/README.md +++ b/examples/ssr_router/README.md @@ -12,7 +12,7 @@ of the function router example. 2. Run the server -`cargo run --bin ssr_router_server -- --dir examples/ssr_router/dist` +`cargo run --features=ssr --bin ssr_router_server -- --dir examples/ssr_router/dist` 3. Open Browser diff --git a/examples/ssr_router/index.html b/examples/ssr_router/index.html index 95eb7d33dd4..817504041ed 100644 --- a/examples/ssr_router/index.html +++ b/examples/ssr_router/index.html @@ -3,7 +3,7 @@ - + Yew • SSR Router ) { ::yew::functional::FunctionComponent::::destroy(&self.function_component) } + + #[inline] + fn prepare_state(&self) -> ::std::option::Option<::std::string::String> { + ::yew::functional::FunctionComponent::::prepare_state(&self.function_component) + } } } } diff --git a/packages/yew-macro/src/lib.rs b/packages/yew-macro/src/lib.rs index b01a9595c44..e5a50ab2250 100644 --- a/packages/yew-macro/src/lib.rs +++ b/packages/yew-macro/src/lib.rs @@ -53,6 +53,8 @@ mod hook; mod html_tree; mod props; mod stringify; +mod use_prepared_state; +mod use_transitive_state; use derive_props::DerivePropsInput; use function_component::{function_component_impl, FunctionComponent, FunctionComponentName}; @@ -62,6 +64,8 @@ use proc_macro::TokenStream; use quote::ToTokens; use syn::buffer::Cursor; use syn::parse_macro_input; +use use_prepared_state::PreparedState; +use use_transitive_state::TransitiveState; trait Peek<'a, T> { fn peek(cursor: Cursor<'a>) -> Option<(T, Cursor<'a>)>; @@ -150,3 +154,27 @@ pub fn hook(attr: TokenStream, item: TokenStream) -> proc_macro::TokenStream { .unwrap_or_else(|err| err.to_compile_error()) .into() } + +#[proc_macro] +pub fn use_prepared_state_with_closure(input: TokenStream) -> TokenStream { + let prepared_state = parse_macro_input!(input as PreparedState); + prepared_state.to_token_stream_with_closure().into() +} + +#[proc_macro] +pub fn use_prepared_state_without_closure(input: TokenStream) -> TokenStream { + let prepared_state = parse_macro_input!(input as PreparedState); + prepared_state.to_token_stream_without_closure().into() +} + +#[proc_macro] +pub fn use_transitive_state_with_closure(input: TokenStream) -> TokenStream { + let transitive_state = parse_macro_input!(input as TransitiveState); + transitive_state.to_token_stream_with_closure().into() +} + +#[proc_macro] +pub fn use_transitive_state_without_closure(input: TokenStream) -> TokenStream { + let transitive_state = parse_macro_input!(input as TransitiveState); + transitive_state.to_token_stream_without_closure().into() +} diff --git a/packages/yew-macro/src/use_prepared_state.rs b/packages/yew-macro/src/use_prepared_state.rs new file mode 100644 index 00000000000..c94240aab1c --- /dev/null +++ b/packages/yew-macro/src/use_prepared_state.rs @@ -0,0 +1,116 @@ +use proc_macro2::TokenStream; +use quote::quote; +use syn::parse::{Parse, ParseStream}; +use syn::{parse_quote, Expr, ExprClosure, ReturnType, Token, Type}; + +#[derive(Debug)] +pub struct PreparedState { + closure: ExprClosure, + return_type: Type, + deps: Expr, +} + +impl Parse for PreparedState { + fn parse(input: ParseStream) -> syn::Result { + // Reads a closure. + let expr: Expr = input.parse()?; + + let closure = match expr { + Expr::Closure(m) => m, + other => return Err(syn::Error::new_spanned(other, "expected closure")), + }; + + input.parse::().map_err(|e| { + syn::Error::new( + e.span(), + "this hook takes 2 arguments but 1 argument was supplied", + ) + })?; + + let return_type = match &closure.output { + ReturnType::Default => { + return Err(syn::Error::new_spanned( + &closure, + "You must specify a return type for this closure. This is used when the \ + closure is omitted from the client side rendering bundle.", + )) + } + ReturnType::Type(_rarrow, ty) => *ty.to_owned(), + }; + + // Reads the deps. + let deps = input.parse()?; + + if !input.is_empty() { + let maybe_trailing_comma = input.lookahead1(); + + if !maybe_trailing_comma.peek(Token![,]) { + return Err(maybe_trailing_comma.error()); + } + } + + Ok(Self { + closure, + return_type, + deps, + }) + } +} + +impl PreparedState { + // Async closure is not stable, so we rewrite it to clsoure + async block + pub fn rewrite_to_closure_with_async_block(&self) -> ExprClosure { + let async_token = match &self.closure.asyncness { + Some(m) => m, + None => return self.closure.clone(), + }; + + let move_token = &self.closure.capture; + let body = &self.closure.body; + + let inner = parse_quote! { + #async_token #move_token { + #body + } + }; + + let mut closure = self.closure.clone(); + + closure.asyncness = None; + // We omit the output type as it's an opaque future type. + closure.output = ReturnType::Default; + + closure.body = inner; + + closure.attrs.push(parse_quote! { #[allow(unused_braces)] }); + + closure + } + + pub fn to_token_stream_with_closure(&self) -> TokenStream { + let deps = &self.deps; + let rt = &self.return_type; + let closure = self.rewrite_to_closure_with_async_block(); + + match &self.closure.asyncness { + Some(_) => quote! { + ::yew::functional::use_prepared_state_with_suspension::<#rt, _, _, _>(#closure, #deps) + }, + None => quote! { + ::yew::functional::use_prepared_state::<#rt, _, _>(#closure, #deps) + }, + } + } + + // Expose a hook for the client side. + // + // The closure is stripped from the client side. + pub fn to_token_stream_without_closure(&self) -> TokenStream { + let deps = &self.deps; + let rt = &self.return_type; + + quote! { + ::yew::functional::use_prepared_state::<#rt, _>(#deps) + } + } +} diff --git a/packages/yew-macro/src/use_transitive_state.rs b/packages/yew-macro/src/use_transitive_state.rs new file mode 100644 index 00000000000..08bf9fc8265 --- /dev/null +++ b/packages/yew-macro/src/use_transitive_state.rs @@ -0,0 +1,82 @@ +use proc_macro2::TokenStream; +use quote::quote; +use syn::parse::{Parse, ParseStream}; +use syn::{Expr, ExprClosure, ReturnType, Token, Type}; + +#[derive(Debug)] +pub struct TransitiveState { + closure: ExprClosure, + return_type: Type, + deps: Expr, +} + +impl Parse for TransitiveState { + fn parse(input: ParseStream) -> syn::Result { + // Reads a closure. + let expr: Expr = input.parse()?; + + let closure = match expr { + Expr::Closure(m) => m, + other => return Err(syn::Error::new_spanned(other, "expected closure")), + }; + + input.parse::().map_err(|e| { + syn::Error::new( + e.span(), + "this hook takes 2 arguments but 1 argument was supplied", + ) + })?; + + let return_type = match &closure.output { + ReturnType::Default => { + return Err(syn::Error::new_spanned( + &closure, + "You must specify a return type for this closure. This is used when the \ + closure is omitted from the client side rendering bundle.", + )) + } + ReturnType::Type(_rarrow, ty) => *ty.to_owned(), + }; + + // Reads the deps. + let deps = input.parse()?; + + if !input.is_empty() { + let maybe_trailing_comma = input.lookahead1(); + + if !maybe_trailing_comma.peek(Token![,]) { + return Err(maybe_trailing_comma.error()); + } + } + + Ok(Self { + closure, + return_type, + deps, + }) + } +} + +impl TransitiveState { + pub fn to_token_stream_with_closure(&self) -> TokenStream { + let deps = &self.deps; + let rt = &self.return_type; + let closure = &self.closure; + + quote! { + ::yew::functional::use_transitive_state::<#rt, _, _>(#closure, #deps) + } + } + + // Expose a hook for the client side. + // + // The closure is stripped from the client side. + pub fn to_token_stream_without_closure(&self) -> TokenStream { + let deps = &self.deps; + let rt = &self.return_type; + + quote! { + ::yew::functional::use_transitive_state::<#rt, _>(#deps) + } + } +} diff --git a/packages/yew-macro/tests/hook_macro/use_prepared_state-fail.rs b/packages/yew-macro/tests/hook_macro/use_prepared_state-fail.rs new file mode 100644 index 00000000000..e12ee98fe64 --- /dev/null +++ b/packages/yew-macro/tests/hook_macro/use_prepared_state-fail.rs @@ -0,0 +1,30 @@ +use yew::prelude::*; +use yew_macro::{use_prepared_state_with_closure, use_prepared_state_without_closure}; + +#[function_component] +fn Comp() -> HtmlResult { + use_prepared_state_with_closure!(123)?; + + use_prepared_state_with_closure!(|_| { todo!() }, 123)?; + + use_prepared_state_with_closure!(|_| -> u32 { todo!() })?; + + use_prepared_state_with_closure!(async |_| -> u32 { todo!() })?; + + Ok(Html::default()) +} + +#[function_component] +fn Comp2() -> HtmlResult { + use_prepared_state_without_closure!(123)?; + + use_prepared_state_without_closure!(|_| { todo!() }, 123)?; + + use_prepared_state_without_closure!(|_| -> u32 { todo!() })?; + + use_prepared_state_without_closure!(async |_| -> u32 { todo!() })?; + + Ok(Html::default()) +} + +fn main() {} diff --git a/packages/yew-macro/tests/hook_macro/use_prepared_state-fail.stderr b/packages/yew-macro/tests/hook_macro/use_prepared_state-fail.stderr new file mode 100644 index 00000000000..62ba0033a56 --- /dev/null +++ b/packages/yew-macro/tests/hook_macro/use_prepared_state-fail.stderr @@ -0,0 +1,55 @@ +error: expected closure + --> tests/hook_macro/use_prepared_state-fail.rs:6:38 + | +6 | use_prepared_state_with_closure!(123)?; + | ^^^ + +error: You must specify a return type for this closure. This is used when the closure is omitted from the client side rendering bundle. + --> tests/hook_macro/use_prepared_state-fail.rs:8:38 + | +8 | use_prepared_state_with_closure!(|_| { todo!() }, 123)?; + | ^^^^^^^^^^^^^^^ + +error: this hook takes 2 arguments but 1 argument was supplied + --> tests/hook_macro/use_prepared_state-fail.rs:10:5 + | +10 | use_prepared_state_with_closure!(|_| -> u32 { todo!() })?; + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + | + = note: this error originates in the macro `use_prepared_state_with_closure` (in Nightly builds, run with -Z macro-backtrace for more info) + +error: this hook takes 2 arguments but 1 argument was supplied + --> tests/hook_macro/use_prepared_state-fail.rs:12:5 + | +12 | use_prepared_state_with_closure!(async |_| -> u32 { todo!() })?; + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + | + = note: this error originates in the macro `use_prepared_state_with_closure` (in Nightly builds, run with -Z macro-backtrace for more info) + +error: expected closure + --> tests/hook_macro/use_prepared_state-fail.rs:19:41 + | +19 | use_prepared_state_without_closure!(123)?; + | ^^^ + +error: You must specify a return type for this closure. This is used when the closure is omitted from the client side rendering bundle. + --> tests/hook_macro/use_prepared_state-fail.rs:21:41 + | +21 | use_prepared_state_without_closure!(|_| { todo!() }, 123)?; + | ^^^^^^^^^^^^^^^ + +error: this hook takes 2 arguments but 1 argument was supplied + --> tests/hook_macro/use_prepared_state-fail.rs:23:5 + | +23 | use_prepared_state_without_closure!(|_| -> u32 { todo!() })?; + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + | + = note: this error originates in the macro `use_prepared_state_without_closure` (in Nightly builds, run with -Z macro-backtrace for more info) + +error: this hook takes 2 arguments but 1 argument was supplied + --> tests/hook_macro/use_prepared_state-fail.rs:25:5 + | +25 | use_prepared_state_without_closure!(async |_| -> u32 { todo!() })?; + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + | + = note: this error originates in the macro `use_prepared_state_without_closure` (in Nightly builds, run with -Z macro-backtrace for more info) diff --git a/packages/yew-macro/tests/hook_macro/use_transitive_state-fail.rs b/packages/yew-macro/tests/hook_macro/use_transitive_state-fail.rs new file mode 100644 index 00000000000..113b0404ed3 --- /dev/null +++ b/packages/yew-macro/tests/hook_macro/use_transitive_state-fail.rs @@ -0,0 +1,26 @@ +use yew::prelude::*; +use yew_macro::{use_transitive_state_with_closure, use_transitive_state_without_closure}; + +#[function_component] +fn Comp() -> HtmlResult { + use_transitive_state_with_closure!(123)?; + + use_transitive_state_with_closure!(|_| { todo!() }, 123)?; + + use_transitive_state_with_closure!(|_| -> u32 { todo!() })?; + + Ok(Html::default()) +} + +#[function_component] +fn Comp2() -> HtmlResult { + use_transitive_state_without_closure!(123)?; + + use_transitive_state_without_closure!(|_| { todo!() }, 123)?; + + use_transitive_state_without_closure!(|_| -> u32 { todo!() })?; + + Ok(Html::default()) +} + +fn main() {} diff --git a/packages/yew-macro/tests/hook_macro/use_transitive_state-fail.stderr b/packages/yew-macro/tests/hook_macro/use_transitive_state-fail.stderr new file mode 100644 index 00000000000..94712261373 --- /dev/null +++ b/packages/yew-macro/tests/hook_macro/use_transitive_state-fail.stderr @@ -0,0 +1,39 @@ +error: expected closure + --> tests/hook_macro/use_transitive_state-fail.rs:6:40 + | +6 | use_transitive_state_with_closure!(123)?; + | ^^^ + +error: You must specify a return type for this closure. This is used when the closure is omitted from the client side rendering bundle. + --> tests/hook_macro/use_transitive_state-fail.rs:8:40 + | +8 | use_transitive_state_with_closure!(|_| { todo!() }, 123)?; + | ^^^^^^^^^^^^^^^ + +error: this hook takes 2 arguments but 1 argument was supplied + --> tests/hook_macro/use_transitive_state-fail.rs:10:5 + | +10 | use_transitive_state_with_closure!(|_| -> u32 { todo!() })?; + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + | + = note: this error originates in the macro `use_transitive_state_with_closure` (in Nightly builds, run with -Z macro-backtrace for more info) + +error: expected closure + --> tests/hook_macro/use_transitive_state-fail.rs:17:43 + | +17 | use_transitive_state_without_closure!(123)?; + | ^^^ + +error: You must specify a return type for this closure. This is used when the closure is omitted from the client side rendering bundle. + --> tests/hook_macro/use_transitive_state-fail.rs:19:43 + | +19 | use_transitive_state_without_closure!(|_| { todo!() }, 123)?; + | ^^^^^^^^^^^^^^^ + +error: this hook takes 2 arguments but 1 argument was supplied + --> tests/hook_macro/use_transitive_state-fail.rs:21:5 + | +21 | use_transitive_state_without_closure!(|_| -> u32 { todo!() })?; + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + | + = note: this error originates in the macro `use_transitive_state_without_closure` (in Nightly builds, run with -Z macro-backtrace for more info) diff --git a/packages/yew-macro/tests/hook_macro_test.rs b/packages/yew-macro/tests/hook_macro_test.rs new file mode 100644 index 00000000000..d860ad28821 --- /dev/null +++ b/packages/yew-macro/tests/hook_macro_test.rs @@ -0,0 +1,7 @@ +#[allow(dead_code)] +#[rustversion::attr(stable(1.56), test)] +fn tests() { + let t = trybuild::TestCases::new(); + t.pass("tests/hook_macro/*-pass.rs"); + t.compile_fail("tests/hook_macro/*-fail.rs"); +} diff --git a/packages/yew/Cargo.toml b/packages/yew/Cargo.toml index 491a44695dd..1571d305790 100644 --- a/packages/yew/Cargo.toml +++ b/packages/yew/Cargo.toml @@ -29,6 +29,9 @@ thiserror = "1.0" futures = { version = "0.3", optional = true } html-escape = { version = "0.2.9", optional = true } +base64ct = { version = "1.5.0", features = ["std"], optional = true } +bincode = { version = "1.3.3", optional = true } +serde = { version = "1", features = ["derive"] } [dependencies.web-sys] version = "0.3" @@ -61,6 +64,7 @@ features = [ "UiEvent", "WheelEvent", "Window", + "HtmlScriptElement", ] [target.'cfg(target_arch = "wasm32")'.dependencies] @@ -89,9 +93,9 @@ features = [ [features] # TODO: `dep:` syntax only supported with MSRV 1.60, would be more precise # tokio = ["dep:tokio"] -ssr = ["futures", "html-escape"] # dep:html-escape +ssr = ["futures", "html-escape", "base64ct", "bincode"] # dep:html-escape csr = [] -hydration = ["csr"] +hydration = ["csr", "bincode"] default = [] [target.'cfg(not(target_arch = "wasm32"))'.dev-dependencies] diff --git a/packages/yew/src/dom_bundle/btag/listeners.rs b/packages/yew/src/dom_bundle/btag/listeners.rs index 55d708af39b..0ebbc345e25 100644 --- a/packages/yew/src/dom_bundle/btag/listeners.rs +++ b/packages/yew/src/dom_bundle/btag/listeners.rs @@ -312,7 +312,7 @@ mod tests { M: Mixin + Properties + Default, { // Remove any existing elements - let body = document().body().unwrap(); + let body = document().query_selector("#output").unwrap().unwrap(); while let Some(child) = body.query_selector("div#testroot").unwrap() { body.remove_child(&child).unwrap(); } diff --git a/packages/yew/src/functional/hooks/mod.rs b/packages/yew/src/functional/hooks/mod.rs index 11c0f0f5fd9..be1cf62e4ba 100644 --- a/packages/yew/src/functional/hooks/mod.rs +++ b/packages/yew/src/functional/hooks/mod.rs @@ -3,18 +3,30 @@ mod use_context; mod use_effect; mod use_force_update; mod use_memo; +#[cfg_attr(documenting, doc(cfg(any(target_arch = "wasm32", feature = "tokio"))))] +#[cfg(any(target_arch = "wasm32", feature = "tokio"))] +mod use_prepared_state; mod use_reducer; mod use_ref; mod use_state; +#[cfg_attr(documenting, doc(cfg(any(target_arch = "wasm32", feature = "tokio"))))] +#[cfg(any(target_arch = "wasm32", feature = "tokio"))] +mod use_transitive_state; pub use use_callback::*; pub use use_context::*; pub use use_effect::*; pub use use_force_update::*; pub use use_memo::*; +#[cfg_attr(documenting, doc(cfg(any(target_arch = "wasm32", feature = "tokio"))))] +#[cfg(any(target_arch = "wasm32", feature = "tokio"))] +pub use use_prepared_state::*; pub use use_reducer::*; pub use use_ref::*; pub use use_state::*; +#[cfg_attr(documenting, doc(cfg(any(target_arch = "wasm32", feature = "tokio"))))] +#[cfg(any(target_arch = "wasm32", feature = "tokio"))] +pub use use_transitive_state::*; use crate::functional::HookContext; diff --git a/packages/yew/src/functional/hooks/use_prepared_state/feat_hydration.rs b/packages/yew/src/functional/hooks/use_prepared_state/feat_hydration.rs new file mode 100644 index 00000000000..597c0c4c790 --- /dev/null +++ b/packages/yew/src/functional/hooks/use_prepared_state/feat_hydration.rs @@ -0,0 +1,122 @@ +//! The client-side rendering variant. This is used for client side rendering. + +use std::marker::PhantomData; +use std::rc::Rc; + +use serde::de::DeserializeOwned; +use serde::Serialize; +use wasm_bindgen::JsValue; + +use super::PreparedStateBase; +use crate::functional::{use_state, Hook, HookContext}; +use crate::io_coop::spawn_local; +use crate::suspense::{Suspension, SuspensionResult}; + +#[cfg(target_arch = "wasm32")] +async fn decode_base64(s: &str) -> Result, JsValue> { + use gloo_utils::window; + use js_sys::Uint8Array; + use wasm_bindgen::JsCast; + use wasm_bindgen_futures::JsFuture; + + let fetch_promise = window().fetch_with_str(s); + + let content_promise = JsFuture::from(fetch_promise) + .await + .and_then(|m| m.dyn_into::()) + .and_then(|m| m.array_buffer())?; + + let content_array = JsFuture::from(content_promise) + .await + .as_ref() + .map(Uint8Array::new)?; + + Ok(content_array.to_vec()) +} + +#[cfg(not(target_arch = "wasm32"))] +async fn decode_base64(_s: &str) -> Result, JsValue> { + unreachable!("this function is not callable under non-wasm targets!"); +} + +#[doc(hidden)] +pub fn use_prepared_state(deps: D) -> impl Hook>>> +where + D: Serialize + DeserializeOwned + PartialEq + 'static, + T: Serialize + DeserializeOwned + 'static, +{ + struct HookProvider + where + D: Serialize + DeserializeOwned + PartialEq + 'static, + T: Serialize + DeserializeOwned + 'static, + { + _marker: PhantomData, + deps: D, + } + + impl Hook for HookProvider + where + D: Serialize + DeserializeOwned + PartialEq + 'static, + T: Serialize + DeserializeOwned + 'static, + { + type Output = SuspensionResult>>; + + fn run(self, ctx: &mut HookContext) -> Self::Output { + let data = use_state(|| { + let (s, handle) = Suspension::new(); + ( + SuspensionResult::<(Option>, Option>)>::Err(s), + Some(handle), + ) + }) + .run(ctx); + + let state = { + let data = data.clone(); + ctx.next_prepared_state(move |_re_render, buf| -> PreparedStateBase { + if let Some(buf) = buf { + let buf = format!("data:application/octet-binary;base64,{}", buf); + + spawn_local(async move { + let buf = decode_base64(&buf) + .await + .expect("failed to deserialize state"); + + let (state, deps) = + bincode::deserialize::<(Option, Option)>(&buf) + .map(|(state, deps)| (state.map(Rc::new), deps.map(Rc::new))) + .expect("failed to deserialize state"); + + data.set((Ok((state, deps)), None)); + }); + } + + PreparedStateBase { + #[cfg(feature = "ssr")] + state: None, + #[cfg(feature = "ssr")] + deps: None, + + has_buf: buf.is_some(), + _marker: PhantomData, + } + }) + }; + + if state.has_buf { + let (data, deps) = data.0.clone()?; + + if deps.as_deref() == Some(&self.deps) { + return Ok(data); + } + } + + Ok(None) + } + } + + HookProvider:: { + _marker: PhantomData, + deps, + } +} diff --git a/packages/yew/src/functional/hooks/use_prepared_state/feat_hydration_ssr.rs b/packages/yew/src/functional/hooks/use_prepared_state/feat_hydration_ssr.rs new file mode 100644 index 00000000000..be7ffab2f04 --- /dev/null +++ b/packages/yew/src/functional/hooks/use_prepared_state/feat_hydration_ssr.rs @@ -0,0 +1,95 @@ +//! The client-and-server-side rendering variant. + +use std::future::Future; +use std::rc::Rc; + +use serde::de::DeserializeOwned; +use serde::Serialize; + +use super::{feat_hydration, feat_ssr}; +use crate::functional::{Hook, HookContext}; +use crate::html::RenderMode; +use crate::suspense::SuspensionResult; + +#[doc(hidden)] +pub fn use_prepared_state( + f: F, + deps: D, +) -> impl Hook>>> +where + D: Serialize + DeserializeOwned + PartialEq + 'static, + T: Serialize + DeserializeOwned + 'static, + F: FnOnce(&D) -> T, +{ + struct HookProvider + where + D: Serialize + DeserializeOwned + PartialEq + 'static, + T: Serialize + DeserializeOwned + 'static, + F: FnOnce(&D) -> T, + { + deps: D, + f: F, + } + + impl Hook for HookProvider + where + D: Serialize + DeserializeOwned + PartialEq + 'static, + T: Serialize + DeserializeOwned + 'static, + F: FnOnce(&D) -> T, + { + type Output = SuspensionResult>>; + + fn run(self, ctx: &mut HookContext) -> Self::Output { + match ctx.mode { + RenderMode::Ssr => feat_ssr::use_prepared_state(self.f, self.deps).run(ctx), + _ => feat_hydration::use_prepared_state(self.deps).run(ctx), + } + } + } + + HookProvider:: { deps, f } +} + +#[doc(hidden)] +pub fn use_prepared_state_with_suspension( + f: F, + deps: D, +) -> impl Hook>>> +where + D: Serialize + DeserializeOwned + PartialEq + 'static, + T: Serialize + DeserializeOwned + 'static, + F: FnOnce(&D) -> U, + U: 'static + Future, +{ + struct HookProvider + where + D: Serialize + DeserializeOwned + PartialEq + 'static, + T: Serialize + DeserializeOwned + 'static, + F: FnOnce(&D) -> U, + U: 'static + Future, + { + deps: D, + f: F, + } + + impl Hook for HookProvider + where + D: Serialize + DeserializeOwned + PartialEq + 'static, + T: Serialize + DeserializeOwned + 'static, + F: FnOnce(&D) -> U, + U: 'static + Future, + { + type Output = SuspensionResult>>; + + fn run(self, ctx: &mut HookContext) -> Self::Output { + match ctx.mode { + RenderMode::Ssr => { + feat_ssr::use_prepared_state_with_suspension(self.f, self.deps).run(ctx) + } + _ => feat_hydration::use_prepared_state(self.deps).run(ctx), + } + } + } + + HookProvider:: { deps, f } +} diff --git a/packages/yew/src/functional/hooks/use_prepared_state/feat_none.rs b/packages/yew/src/functional/hooks/use_prepared_state/feat_none.rs new file mode 100644 index 00000000000..b1fb795e458 --- /dev/null +++ b/packages/yew/src/functional/hooks/use_prepared_state/feat_none.rs @@ -0,0 +1,29 @@ +//! The noop variant. This is used for client side rendering when hydration is disabled. + +use std::rc::Rc; + +use serde::de::DeserializeOwned; +use serde::Serialize; + +use crate::hook; +use crate::suspense::SuspensionResult; + +#[doc(hidden)] +#[hook] +pub fn use_prepared_state(_deps: D) -> SuspensionResult>> +where + D: Serialize + DeserializeOwned + PartialEq + 'static, + T: Serialize + DeserializeOwned + 'static, +{ + Ok(None) +} + +#[doc(hidden)] +#[hook] +pub fn use_prepared_state_with_suspension(_deps: D) -> SuspensionResult>> +where + D: Serialize + DeserializeOwned + PartialEq + 'static, + T: Serialize + DeserializeOwned + 'static, +{ + Ok(None) +} diff --git a/packages/yew/src/functional/hooks/use_prepared_state/feat_ssr.rs b/packages/yew/src/functional/hooks/use_prepared_state/feat_ssr.rs new file mode 100644 index 00000000000..47d08179e4c --- /dev/null +++ b/packages/yew/src/functional/hooks/use_prepared_state/feat_ssr.rs @@ -0,0 +1,143 @@ +//! The server-side rendering variant. This is used for server side rendering. + +use std::future::Future; +use std::marker::PhantomData; +use std::rc::Rc; + +use serde::de::DeserializeOwned; +use serde::Serialize; + +use super::PreparedStateBase; +use crate::functional::{use_memo, use_state, Hook, HookContext}; +use crate::io_coop::spawn_local; +use crate::suspense::{Suspension, SuspensionResult}; + +#[doc(hidden)] +pub fn use_prepared_state( + f: F, + deps: D, +) -> impl Hook>>> +where + D: Serialize + DeserializeOwned + PartialEq + 'static, + T: Serialize + DeserializeOwned + 'static, + F: FnOnce(&D) -> T, +{ + struct HookProvider + where + D: Serialize + DeserializeOwned + PartialEq + 'static, + T: Serialize + DeserializeOwned + 'static, + F: FnOnce(&D) -> T, + { + deps: D, + f: F, + } + + impl Hook for HookProvider + where + D: Serialize + DeserializeOwned + PartialEq + 'static, + T: Serialize + DeserializeOwned + 'static, + F: FnOnce(&D) -> T, + { + type Output = SuspensionResult>>; + + fn run(self, ctx: &mut HookContext) -> Self::Output { + let f = self.f; + let deps = Rc::new(self.deps); + + let state = { + let deps = deps.clone(); + use_memo(move |_| f(&deps), ()).run(ctx) + }; + + let state = PreparedStateBase { + state: Some(state), + deps: Some(deps), + #[cfg(feature = "hydration")] + has_buf: true, + _marker: PhantomData, + }; + + let state = + ctx.next_prepared_state(|_re_render, _| -> PreparedStateBase { state }); + + Ok(state.state.clone()) + } + } + + HookProvider:: { deps, f } +} + +#[doc(hidden)] +pub fn use_prepared_state_with_suspension( + f: F, + deps: D, +) -> impl Hook>>> +where + D: Serialize + DeserializeOwned + PartialEq + 'static, + T: Serialize + DeserializeOwned + 'static, + F: FnOnce(&D) -> U, + U: 'static + Future, +{ + struct HookProvider + where + D: Serialize + DeserializeOwned + PartialEq + 'static, + T: Serialize + DeserializeOwned + 'static, + F: FnOnce(&D) -> U, + U: 'static + Future, + { + deps: D, + f: F, + } + + impl Hook for HookProvider + where + D: Serialize + DeserializeOwned + PartialEq + 'static, + T: Serialize + DeserializeOwned + 'static, + F: FnOnce(&D) -> U, + U: 'static + Future, + { + type Output = SuspensionResult>>; + + fn run(self, ctx: &mut HookContext) -> Self::Output { + let f = self.f; + let deps = Rc::new(self.deps); + + let result = use_state(|| { + let (s, handle) = Suspension::new(); + (Err(s), Some(handle)) + }) + .run(ctx); + + { + let deps = deps.clone(); + let result = result.clone(); + use_state(move || { + let state_f = f(&deps); + + spawn_local(async move { + let state = state_f.await; + result.set((Ok(Rc::new(state)), None)); + }) + }) + .run(ctx); + } + + let state = result.0.clone()?; + + let state = PreparedStateBase { + state: Some(state), + deps: Some(deps), + #[cfg(feature = "hydration")] + has_buf: true, + _marker: PhantomData, + }; + + let state = + ctx.next_prepared_state(|_re_render, _| -> PreparedStateBase { state }); + + Ok(state.state.clone()) + } + } + + HookProvider:: { deps, f } +} diff --git a/packages/yew/src/functional/hooks/use_prepared_state/mod.rs b/packages/yew/src/functional/hooks/use_prepared_state/mod.rs new file mode 100644 index 00000000000..c54f9de9f66 --- /dev/null +++ b/packages/yew/src/functional/hooks/use_prepared_state/mod.rs @@ -0,0 +1,146 @@ +#[cfg(feature = "hydration")] +pub(super) mod feat_hydration; +#[cfg(all(feature = "hydration", feature = "ssr"))] +mod feat_hydration_ssr; +#[cfg(not(any(feature = "hydration", feature = "ssr")))] +pub(super) mod feat_none; +#[cfg(feature = "ssr")] +mod feat_ssr; + +#[cfg(all(feature = "hydration", not(feature = "ssr")))] +pub use feat_hydration::*; +#[cfg(all(feature = "ssr", feature = "hydration"))] +pub use feat_hydration_ssr::*; +#[cfg(not(any(feature = "hydration", feature = "ssr")))] +pub use feat_none::*; +#[cfg(all(feature = "ssr", not(feature = "hydration")))] +pub use feat_ssr::*; +/// Use a state prepared on the server side and its value is sent to the client side during +/// hydration. +/// +/// The component sees the same value on the server side and client side if the component is +/// hydrated. +/// +/// It accepts a closure as the first argument and a dependency type as the second argument. +/// It returns `SuspensionResult>>`. +/// +/// During hydration, it will only return `Ok(Some(Rc))` if the component is hydrated from a +/// server-side rendering artifact and its dependency value matches. +/// +/// `let state = use_prepared_state!(|deps| -> ReturnType { ... }, deps)?;` +/// +/// It has the following signature: +/// +/// ``` +/// # use serde::de::DeserializeOwned; +/// # use serde::Serialize; +/// # use std::rc::Rc; +/// use yew::prelude::*; +/// use yew::suspense::SuspensionResult; +/// +/// #[hook] +/// pub fn use_prepared_state(f: F, deps: D) -> SuspensionResult>> +/// where +/// D: Serialize + DeserializeOwned + PartialEq + 'static, +/// T: Serialize + DeserializeOwned + 'static, +/// F: FnOnce(&D) -> T, +/// # { todo!() } +/// ``` +/// +/// The first argument can also be an [async closure](https://github.com/rust-lang/rust/issues/62290). +/// +/// `let state = use_prepared_state!(async |deps| -> ReturnType { ... }, deps)?;` +/// +/// When accepting an async closure, it has the following signature: +/// +/// ``` +/// # use serde::de::DeserializeOwned; +/// # use serde::Serialize; +/// # use std::rc::Rc; +/// # use std::future::Future; +/// use yew::prelude::*; +/// use yew::suspense::SuspensionResult; +/// +/// #[hook] +/// pub fn use_prepared_state( +/// f: F, +/// deps: D, +/// ) -> SuspensionResult>> +/// where +/// D: Serialize + DeserializeOwned + PartialEq + 'static, +/// T: Serialize + DeserializeOwned + 'static, +/// F: FnOnce(&D) -> U, +/// U: 'static + Future, +/// # { todo!() } +/// ``` +/// +/// During server-side rendering, a value of type `T` will be calculated from the first +/// closure. +/// +/// If the bundle is compiled without server-side rendering, the closure will be stripped +/// automatically. +/// +/// # Note +/// +/// You MUST denote the return type of the closure with `|deps| -> ReturnType { ... }`. This +/// type is used during client side rendering to deserialize the state prepared on the server +/// side. +/// +/// Whilst async closure is an unstable feature, the procedural macro will rewrite this to a +/// closure that returns an async block automatically. You can use this hook with async closure +/// in stable Rust. +#[cfg_attr(documenting, doc(cfg(any(target_arch = "wasm32", feature = "tokio"))))] +pub use use_prepared_state_macro as use_prepared_state; +// With SSR. +#[doc(hidden)] +#[cfg(feature = "ssr")] +pub use yew_macro::use_prepared_state_with_closure as use_prepared_state_macro; +// Without SSR. +#[doc(hidden)] +#[cfg(not(feature = "ssr"))] +pub use yew_macro::use_prepared_state_without_closure as use_prepared_state_macro; + +#[cfg(any(feature = "hydration", feature = "ssr"))] +mod feat_any_hydration_ssr { + use std::marker::PhantomData; + #[cfg(feature = "ssr")] + use std::rc::Rc; + + use serde::de::DeserializeOwned; + use serde::Serialize; + + use crate::functional::PreparedState; + + pub(super) struct PreparedStateBase + where + D: Serialize + DeserializeOwned + PartialEq + 'static, + T: Serialize + DeserializeOwned + 'static, + { + #[cfg(feature = "ssr")] + pub state: Option>, + #[cfg(feature = "ssr")] + pub deps: Option>, + #[cfg(feature = "hydration")] + pub has_buf: bool, + pub _marker: PhantomData<(T, D)>, + } + + impl PreparedState for PreparedStateBase + where + D: Serialize + DeserializeOwned + PartialEq + 'static, + T: Serialize + DeserializeOwned + 'static, + { + #[cfg(feature = "ssr")] + fn prepare(&self) -> String { + use base64ct::{Base64, Encoding}; + + let state = bincode::serialize(&(self.state.as_deref(), self.deps.as_deref())) + .expect("failed to prepare state"); + + Base64::encode_string(&state) + } + } +} + +#[cfg(any(feature = "hydration", feature = "ssr"))] +use feat_any_hydration_ssr::PreparedStateBase; diff --git a/packages/yew/src/functional/hooks/use_transitive_state/feat_hydration.rs b/packages/yew/src/functional/hooks/use_transitive_state/feat_hydration.rs new file mode 100644 index 00000000000..b779ddceb3a --- /dev/null +++ b/packages/yew/src/functional/hooks/use_transitive_state/feat_hydration.rs @@ -0,0 +1,6 @@ +//! The hydration variant. +//! +//! This is the same as the use_prepared_state. + +#[doc(hidden)] +pub use crate::functional::hooks::use_prepared_state::feat_hydration::use_prepared_state as use_transitive_state; diff --git a/packages/yew/src/functional/hooks/use_transitive_state/feat_hydration_ssr.rs b/packages/yew/src/functional/hooks/use_transitive_state/feat_hydration_ssr.rs new file mode 100644 index 00000000000..c0ed6f9edda --- /dev/null +++ b/packages/yew/src/functional/hooks/use_transitive_state/feat_hydration_ssr.rs @@ -0,0 +1,50 @@ +//! The client-and-server-side rendering variant. + +use std::rc::Rc; + +use serde::de::DeserializeOwned; +use serde::Serialize; + +use super::{feat_hydration, feat_ssr}; +use crate::functional::{Hook, HookContext}; +use crate::html::RenderMode; +use crate::suspense::SuspensionResult; + +#[doc(hidden)] +pub fn use_transitive_state( + f: F, + deps: D, +) -> impl Hook>>> +where + D: Serialize + DeserializeOwned + PartialEq + 'static, + T: Serialize + DeserializeOwned + 'static, + F: 'static + FnOnce(&D) -> T, +{ + struct HookProvider + where + D: Serialize + DeserializeOwned + PartialEq + 'static, + T: Serialize + DeserializeOwned + 'static, + F: 'static + FnOnce(&D) -> T, + { + deps: D, + f: F, + } + + impl Hook for HookProvider + where + D: Serialize + DeserializeOwned + PartialEq + 'static, + T: Serialize + DeserializeOwned + 'static, + F: 'static + FnOnce(&D) -> T, + { + type Output = SuspensionResult>>; + + fn run(self, ctx: &mut HookContext) -> Self::Output { + match ctx.mode { + RenderMode::Ssr => feat_ssr::use_transitive_state(self.f, self.deps).run(ctx), + _ => feat_hydration::use_transitive_state(self.deps).run(ctx), + } + } + } + + HookProvider:: { deps, f } +} diff --git a/packages/yew/src/functional/hooks/use_transitive_state/feat_none.rs b/packages/yew/src/functional/hooks/use_transitive_state/feat_none.rs new file mode 100644 index 00000000000..f5c4651b600 --- /dev/null +++ b/packages/yew/src/functional/hooks/use_transitive_state/feat_none.rs @@ -0,0 +1,4 @@ +//! The noop variant. This is used for client side rendering when hydration is disabled. + +#[doc(hidden)] +pub use crate::functional::hooks::use_prepared_state::feat_none::use_prepared_state as use_transitive_state; diff --git a/packages/yew/src/functional/hooks/use_transitive_state/feat_ssr.rs b/packages/yew/src/functional/hooks/use_transitive_state/feat_ssr.rs new file mode 100644 index 00000000000..fdb35bef800 --- /dev/null +++ b/packages/yew/src/functional/hooks/use_transitive_state/feat_ssr.rs @@ -0,0 +1,83 @@ +//! The server-side rendering variant. + +use std::cell::RefCell; +use std::rc::Rc; + +use base64ct::{Base64, Encoding}; +use serde::de::DeserializeOwned; +use serde::Serialize; + +use crate::functional::{Hook, HookContext, PreparedState}; +use crate::suspense::SuspensionResult; + +pub(super) struct TransitiveStateBase +where + D: Serialize + DeserializeOwned + PartialEq + 'static, + T: Serialize + DeserializeOwned + 'static, + F: 'static + FnOnce(&D) -> T, +{ + pub state_fn: RefCell>, + pub deps: D, +} + +impl PreparedState for TransitiveStateBase +where + D: Serialize + DeserializeOwned + PartialEq + 'static, + T: Serialize + DeserializeOwned + 'static, + F: 'static + FnOnce(&D) -> T, +{ + fn prepare(&self) -> String { + let f = self.state_fn.borrow_mut().take().unwrap(); + let state = f(&self.deps); + + let state = + bincode::serialize(&(Some(&state), Some(&self.deps))).expect("failed to prepare state"); + + Base64::encode_string(&state) + } +} + +#[doc(hidden)] +pub fn use_transitive_state( + f: F, + deps: D, +) -> impl Hook>>> +where + D: Serialize + DeserializeOwned + PartialEq + 'static, + T: Serialize + DeserializeOwned + 'static, + F: 'static + FnOnce(&D) -> T, +{ + struct HookProvider + where + D: Serialize + DeserializeOwned + PartialEq + 'static, + T: Serialize + DeserializeOwned + 'static, + F: 'static + FnOnce(&D) -> T, + { + deps: D, + f: F, + } + + impl Hook for HookProvider + where + D: Serialize + DeserializeOwned + PartialEq + 'static, + T: Serialize + DeserializeOwned + 'static, + F: 'static + FnOnce(&D) -> T, + { + type Output = SuspensionResult>>; + + fn run(self, ctx: &mut HookContext) -> Self::Output { + let f = self.f; + + ctx.next_prepared_state(move |_re_render, _| -> TransitiveStateBase { + TransitiveStateBase { + state_fn: Some(f).into(), + deps: self.deps, + } + }); + + Ok(None) + } + } + + HookProvider:: { deps, f } +} diff --git a/packages/yew/src/functional/hooks/use_transitive_state/mod.rs b/packages/yew/src/functional/hooks/use_transitive_state/mod.rs new file mode 100644 index 00000000000..f8ef487b5cb --- /dev/null +++ b/packages/yew/src/functional/hooks/use_transitive_state/mod.rs @@ -0,0 +1,67 @@ +#[cfg(feature = "hydration")] +mod feat_hydration; +#[cfg(all(feature = "ssr", feature = "hydration"))] +mod feat_hydration_ssr; +#[cfg(not(any(feature = "hydration", feature = "ssr")))] +mod feat_none; +#[cfg(feature = "ssr")] +mod feat_ssr; + +#[cfg(all(feature = "hydration", not(feature = "ssr")))] +pub use feat_hydration::*; +#[cfg(all(feature = "ssr", feature = "hydration"))] +pub use feat_hydration_ssr::*; +#[cfg(not(any(feature = "hydration", feature = "ssr")))] +pub use feat_none::*; +#[cfg(all(feature = "ssr", not(feature = "hydration")))] +pub use feat_ssr::*; +/// Use a state created as an artifact of the server-side rendering. +/// +/// This value is created after the server-side rendering artifact is created. +/// +/// It accepts a closure as the first argument and a dependency type as the second argument. +/// It returns `SuspensionResult>>`. +/// +/// It will always return `Ok(None)` during server-side rendering. +/// +/// During hydration, it will only return `Ok(Some(Rc))` if the component is hydrated from a +/// server-side rendering artifact and its dependency value matches. +/// +/// `let state = use_transitive_state!(|deps| -> ReturnType { ... }, deps);` +/// +/// It has the following function signature: +/// +/// ``` +/// # use serde::de::DeserializeOwned; +/// # use serde::Serialize; +/// # use std::rc::Rc; +/// use yew::prelude::*; +/// use yew::suspense::SuspensionResult; +/// +/// #[hook] +/// pub fn use_transitive_state(f: F, deps: D) -> SuspensionResult>> +/// where +/// D: Serialize + DeserializeOwned + PartialEq + 'static, +/// T: Serialize + DeserializeOwned + 'static, +/// F: 'static + FnOnce(&D) -> T, +/// # { todo!() } +/// ``` +/// +/// If the bundle is compiled without server-side rendering, the closure will be stripped +/// automatically. +/// +/// # Note +/// +/// You MUST denote the return type of the closure with `|deps| -> ReturnType { ... }`. This +/// type is used during client side rendering to deserialize the state prepared on the server +/// side. +#[cfg_attr(documenting, doc(cfg(any(target_arch = "wasm32", feature = "tokio"))))] +pub use use_transitive_state_macro as use_transitive_state; +// With SSR. +#[doc(hidden)] +#[cfg(feature = "ssr")] +pub use yew_macro::use_transitive_state_with_closure as use_transitive_state_macro; +// Without SSR. +#[doc(hidden)] +#[cfg(not(feature = "ssr"))] +pub use yew_macro::use_transitive_state_without_closure as use_transitive_state_macro; diff --git a/packages/yew/src/functional/mod.rs b/packages/yew/src/functional/mod.rs index 7cde8da7a3a..094a995388d 100644 --- a/packages/yew/src/functional/mod.rs +++ b/packages/yew/src/functional/mod.rs @@ -27,6 +27,9 @@ use std::rc::Rc; use wasm_bindgen::prelude::*; +#[cfg(any(target_arch = "wasm32", feature = "tokio"))] +#[cfg(all(feature = "hydration", feature = "ssr"))] +use crate::html::RenderMode; use crate::html::{AnyScope, BaseComponent, Context, HtmlResult}; use crate::Properties; @@ -64,7 +67,15 @@ pub use yew_macro::hook; type ReRender = Rc; -/// Primitives of a Hook state. +/// Primitives of a prepared state hook. +#[cfg(any(target_arch = "wasm32", feature = "tokio"))] +#[cfg(any(feature = "hydration", feature = "ssr"))] +pub(crate) trait PreparedState { + #[cfg(feature = "ssr")] + fn prepare(&self) -> String; +} + +/// Primitives of an effect hook. pub(crate) trait Effect { fn rendered(&self) {} } @@ -72,24 +83,68 @@ pub(crate) trait Effect { /// A hook context to be passed to hooks. pub struct HookContext { pub(crate) scope: AnyScope, + #[cfg(any(target_arch = "wasm32", feature = "tokio"))] + #[cfg(all(feature = "hydration", feature = "ssr"))] + mode: RenderMode, re_render: ReRender, states: Vec>, effects: Vec>, + #[cfg(any(target_arch = "wasm32", feature = "tokio"))] + #[cfg(any(feature = "hydration", feature = "ssr"))] + prepared_states: Vec>, + + #[cfg(any(target_arch = "wasm32", feature = "tokio"))] + #[cfg(feature = "hydration")] + prepared_states_data: Vec>, + #[cfg(any(target_arch = "wasm32", feature = "tokio"))] + #[cfg(feature = "hydration")] + prepared_state_counter: usize, + counter: usize, #[cfg(debug_assertions)] total_hook_counter: Option, } impl HookContext { - fn new(scope: AnyScope, re_render: ReRender) -> RefCell { + fn new( + scope: AnyScope, + re_render: ReRender, + #[cfg(any(target_arch = "wasm32", feature = "tokio"))] + #[cfg(all(feature = "hydration", feature = "ssr"))] + mode: RenderMode, + #[cfg(any(target_arch = "wasm32", feature = "tokio"))] + #[cfg(feature = "hydration")] + prepared_state: Option<&str>, + ) -> RefCell { RefCell::new(HookContext { - effects: Vec::new(), scope, re_render, + + #[cfg(any(target_arch = "wasm32", feature = "tokio"))] + #[cfg(all(feature = "hydration", feature = "ssr"))] + mode, + states: Vec::new(), + #[cfg(any(target_arch = "wasm32", feature = "tokio"))] + #[cfg(any(feature = "hydration", feature = "ssr"))] + prepared_states: Vec::new(), + effects: Vec::new(), + + #[cfg(any(target_arch = "wasm32", feature = "tokio"))] + #[cfg(feature = "hydration")] + prepared_states_data: { + match prepared_state { + Some(m) => m.split(',').map(Rc::from).collect(), + None => Vec::new(), + } + }, + #[cfg(any(target_arch = "wasm32", feature = "tokio"))] + #[cfg(feature = "hydration")] + prepared_state_counter: 0, + counter: 0, #[cfg(debug_assertions)] total_hook_counter: None, @@ -132,8 +187,45 @@ impl HookContext { t } + #[cfg(any(target_arch = "wasm32", feature = "tokio"))] + #[cfg(any(feature = "hydration", feature = "ssr"))] + pub(crate) fn next_prepared_state( + &mut self, + initializer: impl FnOnce(ReRender, Option<&str>) -> T, + ) -> Rc + where + T: 'static + PreparedState, + { + #[cfg(not(feature = "hydration"))] + let prepared_state = Option::>::None; + + #[cfg(feature = "hydration")] + let prepared_state = { + let prepared_state_pos = self.prepared_state_counter; + self.prepared_state_counter += 1; + + self.prepared_states_data.get(prepared_state_pos).cloned() + }; + + let prev_state_len = self.states.len(); + let t = self.next_state(move |re_render| initializer(re_render, prepared_state.as_deref())); + + // This is a new effect, we add it to effects. + if self.states.len() != prev_state_len { + self.prepared_states.push(t.clone()); + } + + t + } + #[inline(always)] fn prepare_run(&mut self) { + #[cfg(any(target_arch = "wasm32", feature = "tokio"))] + #[cfg(feature = "hydration")] + { + self.prepared_state_counter = 0; + } + self.counter = 0; } @@ -184,6 +276,33 @@ impl HookContext { drop(state); } } + + #[cfg(any( + not(feature = "ssr"), + not(any(target_arch = "wasm32", feature = "tokio")) + ))] + fn prepare_state(&self) -> Option { + None + } + + #[cfg(any(target_arch = "wasm32", feature = "tokio"))] + #[cfg(feature = "ssr")] + fn prepare_state(&self) -> Option { + if self.prepared_states.is_empty() { + return None; + } + + let prepared_states = self.prepared_states.clone(); + + let mut states = Vec::new(); + + for state in prepared_states.iter() { + let state = state.prepare(); + states.push(state); + } + + Some(states.join(",")) + } } impl fmt::Debug for HookContext { @@ -239,7 +358,16 @@ where Self { _never: std::marker::PhantomData::default(), - hook_ctx: HookContext::new(scope, re_render), + hook_ctx: HookContext::new( + scope, + re_render, + #[cfg(any(target_arch = "wasm32", feature = "tokio"))] + #[cfg(all(feature = "hydration", feature = "ssr"))] + ctx.mode(), + #[cfg(any(target_arch = "wasm32", feature = "tokio"))] + #[cfg(feature = "hydration")] + ctx.prepared_state(), + ), } } @@ -269,6 +397,12 @@ where let mut hook_ctx = self.hook_ctx.borrow_mut(); hook_ctx.drain_states(); } + + /// Prepares the server-side state. + pub fn prepare_state(&self) -> Option { + let hook_ctx = self.hook_ctx.borrow(); + hook_ctx.prepare_state() + } } impl fmt::Debug for FunctionComponent diff --git a/packages/yew/src/html/component/lifecycle.rs b/packages/yew/src/html/component/lifecycle.rs index 991b4cc28dc..c01e8c993e6 100644 --- a/packages/yew/src/html/component/lifecycle.rs +++ b/packages/yew/src/html/component/lifecycle.rs @@ -228,10 +228,11 @@ pub(crate) struct ComponentState { } impl ComponentState { - pub(crate) fn new( + fn new( initial_render_state: ComponentRenderState, scope: Scope, props: Rc, + #[cfg(feature = "hydration")] prepared_state: Option, ) -> Self { let comp_id = scope.id; #[cfg(feature = "hydration")] @@ -249,6 +250,8 @@ impl ComponentState { props, #[cfg(feature = "hydration")] mode, + #[cfg(feature = "hydration")] + prepared_state, }; let inner = Box::new(CompStateInner { @@ -283,6 +286,8 @@ pub(crate) struct CreateRunner { pub initial_render_state: ComponentRenderState, pub props: Rc, pub scope: Scope, + #[cfg(feature = "hydration")] + pub prepared_state: Option, } impl Runnable for CreateRunner { @@ -296,6 +301,8 @@ impl Runnable for CreateRunner { self.initial_render_state, self.scope.clone(), self.props, + #[cfg(feature = "hydration")] + self.prepared_state, )); } } diff --git a/packages/yew/src/html/component/mod.rs b/packages/yew/src/html/component/mod.rs index 8f19f066de0..2a2ed28f4df 100644 --- a/packages/yew/src/html/component/mod.rs +++ b/packages/yew/src/html/component/mod.rs @@ -75,6 +75,9 @@ pub struct Context { props: Rc, #[cfg(feature = "hydration")] mode: RenderMode, + + #[cfg(feature = "hydration")] + prepared_state: Option, } impl Context { @@ -94,6 +97,17 @@ impl Context { pub(crate) fn mode(&self) -> RenderMode { self.mode } + + /// The component's prepared state + pub fn prepared_state(&self) -> Option<&str> { + #[cfg(not(feature = "hydration"))] + let state = None; + + #[cfg(feature = "hydration")] + let state = self.prepared_state.as_deref(); + + state + } } /// The common base of both function components and struct components. @@ -127,6 +141,9 @@ pub trait BaseComponent: Sized + 'static { /// Notified before a component is destroyed. fn destroy(&mut self, ctx: &Context); + + /// Prepares the server-side state. + fn prepare_state(&self) -> Option; } /// Components are the basic building blocks of the UI in a Yew app. Each Component @@ -186,6 +203,16 @@ pub trait Component: Sized + 'static { #[allow(unused_variables)] fn rendered(&mut self, ctx: &Context, first_render: bool) {} + /// Prepares the state during server side rendering. + /// + /// This state will be sent to the client side and is available via `ctx.prepared_state()`. + /// + /// This method is only called during server-side rendering after the component has been + /// rendered. + fn prepare_state(&self) -> Option { + None + } + /// Called right before a Component is unmounted. #[allow(unused_variables)] fn destroy(&mut self, ctx: &Context) {} @@ -221,4 +248,8 @@ where fn destroy(&mut self, ctx: &Context) { Component::destroy(self, ctx) } + + fn prepare_state(&self) -> Option { + Component::prepare_state(self) + } } diff --git a/packages/yew/src/html/component/scope.rs b/packages/yew/src/html/component/scope.rs index b3ca005e0ea..6ff8f48e5c9 100644 --- a/packages/yew/src/html/component/scope.rs +++ b/packages/yew/src/html/component/scope.rs @@ -285,6 +285,8 @@ mod feat_ssr { initial_render_state: state, props, scope: self.clone(), + #[cfg(feature = "hydration")] + prepared_state: None, }), Box::new(RenderRunner { state: self.state.clone(), @@ -303,6 +305,12 @@ mod feat_ssr { let self_any_scope = AnyScope::from(self.clone()); html.render_to_string(w, &self_any_scope, hydratable).await; + if let Some(prepared_state) = self.get_component().unwrap().prepare_state() { + w.push_str(r#""#); + } + if hydratable { collectable.write_close_tag(w); } @@ -501,7 +509,6 @@ mod feat_csr { /// Mounts a component with `props` to the specified `element` in the DOM. pub(crate) fn mount_in_place( &self, - root: BSubtree, parent: Element, next_sibling: NodeRef, @@ -524,6 +531,8 @@ mod feat_csr { initial_render_state: state, props, scope: self.clone(), + #[cfg(feature = "hydration")] + prepared_state: None, }), Box::new(RenderRunner { state: self.state.clone(), @@ -596,7 +605,8 @@ pub(crate) use feat_csr::*; #[cfg_attr(documenting, doc(cfg(feature = "hydration")))] #[cfg(feature = "hydration")] mod feat_hydration { - use web_sys::Element; + use wasm_bindgen::JsCast; + use web_sys::{Element, HtmlScriptElement}; use super::*; use crate::dom_bundle::{BSubtree, Fragment}; @@ -636,10 +646,23 @@ mod feat_hydration { let collectable = Collectable::for_component::(); - let fragment = Fragment::collect_between(fragment, &collectable, &parent); + let mut fragment = Fragment::collect_between(fragment, &collectable, &parent); node_ref.set(fragment.front().cloned()); let next_sibling = NodeRef::default(); + let prepared_state = match fragment + .back() + .cloned() + .and_then(|m| m.dyn_into::().ok()) + { + Some(m) if m.type_() == "application/x-yew-comp-state" => { + fragment.pop_back(); + parent.remove_child(&m).unwrap(); + Some(m.text().unwrap()) + } + _ => None, + }; + let state = ComponentRenderState::Hydration { root, parent, @@ -654,6 +677,7 @@ mod feat_hydration { initial_render_state: state, props, scope: self.clone(), + prepared_state, }), Box::new(RenderRunner { state: self.state.clone(), diff --git a/packages/yew/tests/use_prepared_state.rs b/packages/yew/tests/use_prepared_state.rs new file mode 100644 index 00000000000..93c52e71b06 --- /dev/null +++ b/packages/yew/tests/use_prepared_state.rs @@ -0,0 +1,114 @@ +#![cfg(target_arch = "wasm32")] +#![cfg(feature = "hydration")] + +use std::time::Duration; + +mod common; + +use common::obtain_result_by_id; +use gloo::timers::future::sleep; +use wasm_bindgen_test::*; +use yew::prelude::*; +use yew::{Renderer, ServerRenderer}; + +wasm_bindgen_test::wasm_bindgen_test_configure!(run_in_browser); + +#[wasm_bindgen_test] +async fn use_prepared_state_works() { + #[function_component] + fn Comp() -> HtmlResult { + let ctr = use_prepared_state!(|_| -> u32 { 12345 }, ())?.unwrap_or_default(); + + Ok(html! { +
+ {*ctr} +
+ }) + } + + #[function_component] + fn App() -> Html { + html! { + +
+ +
+
+ } + } + + let s = ServerRenderer::::new().render().await; + + assert_eq!( + s, + r#"
12345
"# + ); + + gloo::utils::document() + .query_selector("#output") + .unwrap() + .unwrap() + .set_inner_html(&s); + + sleep(Duration::ZERO).await; + + Renderer::::with_root(gloo_utils::document().get_element_by_id("output").unwrap()) + .hydrate(); + + sleep(Duration::from_millis(100)).await; + + let result = obtain_result_by_id("output"); + + // no placeholders, hydration is successful and state 12345 is preserved. + assert_eq!(result, r#"
12345
"#); +} + +#[wasm_bindgen_test] +async fn use_prepared_state_with_suspension_works() { + #[function_component] + fn Comp() -> HtmlResult { + let ctr = use_prepared_state!(async |_| -> u32 { 12345 }, ())?.unwrap_or_default(); + + Ok(html! { +
+ {*ctr} +
+ }) + } + + #[function_component] + fn App() -> Html { + html! { + +
+ +
+
+ } + } + + let s = ServerRenderer::::new().render().await; + + assert_eq!( + s, + r#"
12345
"# + ); + + gloo::utils::document() + .query_selector("#output") + .unwrap() + .unwrap() + .set_inner_html(&s); + + sleep(Duration::ZERO).await; + + Renderer::::with_root(gloo_utils::document().get_element_by_id("output").unwrap()) + .hydrate(); + + sleep(Duration::from_millis(100)).await; + + let result = obtain_result_by_id("output"); + + // no placeholders, hydration is successful and state 12345 is preserved. + assert_eq!(result, r#"
12345
"#); +} diff --git a/packages/yew/tests/use_transitive_state.rs b/packages/yew/tests/use_transitive_state.rs new file mode 100644 index 00000000000..3e180d2187d --- /dev/null +++ b/packages/yew/tests/use_transitive_state.rs @@ -0,0 +1,65 @@ +#![cfg(feature = "hydration")] +#![cfg(target_arch = "wasm32")] + +use std::time::Duration; + +mod common; + +use common::obtain_result_by_id; +use gloo::timers::future::sleep; +use wasm_bindgen_test::*; +use yew::prelude::*; +use yew::{Renderer, ServerRenderer}; + +wasm_bindgen_test::wasm_bindgen_test_configure!(run_in_browser); + +#[wasm_bindgen_test] +async fn use_transitive_state_works() { + #[function_component] + fn Comp() -> HtmlResult { + let ctr = use_transitive_state!(|_| -> u32 { 12345 }, ())?.unwrap_or_default(); + + Ok(html! { +
+ {*ctr} +
+ }) + } + + #[function_component] + fn App() -> Html { + html! { + +
+ +
+
+ } + } + + let s = ServerRenderer::::new().render().await; + + assert_eq!( + s, + // div text content should be 0 but state should be 12345. + r#"
0
"# + ); + + gloo::utils::document() + .query_selector("#output") + .unwrap() + .unwrap() + .set_inner_html(&s); + + sleep(Duration::ZERO).await; + + Renderer::::with_root(gloo_utils::document().get_element_by_id("output").unwrap()) + .hydrate(); + + sleep(Duration::from_millis(100)).await; + + let result = obtain_result_by_id("output"); + + // no placeholders, hydration is successful and div text content now becomes 12345. + assert_eq!(result, r#"
12345
"#); +}