diff --git a/.github/ISSUE_TEMPLATE/documentation.md b/.github/ISSUE_TEMPLATE/documentation.md index 7acc4951a8e..6403d2dab7e 100644 --- a/.github/ISSUE_TEMPLATE/documentation.md +++ b/.github/ISSUE_TEMPLATE/documentation.md @@ -11,7 +11,7 @@ assignees: '' This is about: - [ ] A typo -- [ ] Innaccurate/misleading documentation (e.g. technically incorrect advice) +- [ ] Inaccurate/misleading documentation (e.g. technically incorrect advice) - [ ] Undocumented code - [ ] Outdated documentation - [ ] Other diff --git a/examples/file_upload/Cargo.toml b/examples/file_upload/Cargo.toml index be7408989b2..c01c3d5bc88 100644 --- a/examples/file_upload/Cargo.toml +++ b/examples/file_upload/Cargo.toml @@ -13,4 +13,4 @@ gloo = "0.11" [dependencies.web-sys] version = "0.3" -features = ["File", "DragEvent", "DataTransfer"] +features = ["DataTransfer"] diff --git a/examples/file_upload/src/main.rs b/examples/file_upload/src/main.rs index 1e882fcbda7..49d66bb7906 100644 --- a/examples/file_upload/src/main.rs +++ b/examples/file_upload/src/main.rs @@ -4,20 +4,19 @@ use std::collections::HashMap; use base64::engine::general_purpose::STANDARD; use base64::Engine; use gloo::file::callbacks::FileReader; -use gloo::file::File; -use web_sys::{DragEvent, Event, FileList, HtmlInputElement}; +use web_sys::{DragEvent, Event, HtmlInputElement}; use yew::html::TargetCast; use yew::{html, Callback, Component, Context, Html}; -struct FileDetails { +pub struct FileDetails { name: String, file_type: String, data: Vec, } pub enum Msg { - Loaded(String, String, Vec), - Files(Vec), + Loaded(FileDetails), + Files(Option), } pub struct App { @@ -38,33 +37,27 @@ impl Component for App { fn update(&mut self, ctx: &Context, msg: Self::Message) -> bool { match msg { - Msg::Loaded(file_name, file_type, data) => { - self.files.push(FileDetails { - data, - file_type, - name: file_name.clone(), - }); - self.readers.remove(&file_name); + Msg::Loaded(file) => { + self.readers.remove(&file.name); + self.files.push(file); true } Msg::Files(files) => { - for file in files.into_iter() { - let file_name = file.name(); + for file in gloo::file::FileList::from(files.expect("files")).iter() { + let link = ctx.link().clone(); + let name = file.name().clone(); let file_type = file.raw_mime_type(); let task = { - let link = ctx.link().clone(); - let file_name = file_name.clone(); - - gloo::file::callbacks::read_as_bytes(&file, move |res| { - link.send_message(Msg::Loaded( - file_name, + gloo::file::callbacks::read_as_bytes(file, move |res| { + link.send_message(Msg::Loaded(FileDetails { + data: res.expect("failed to read file"), file_type, - res.expect("failed to read file"), - )) + name, + })) }) }; - self.readers.insert(file_name, task); + self.readers.insert(file.name(), task); } true } @@ -72,6 +65,10 @@ impl Component for App { } fn view(&self, ctx: &Context) -> Html { + let noop_drag = Callback::from(|e: DragEvent| { + e.prevent_default(); + }); + html! {

{ "Upload Your Files To The Cloud" }

@@ -80,15 +77,10 @@ impl Component for App { id="drop-container" ondrop={ctx.link().callback(|event: DragEvent| { event.prevent_default(); - let files = event.data_transfer().unwrap().files(); - Self::upload_files(files) - })} - ondragover={Callback::from(|event: DragEvent| { - event.prevent_default(); - })} - ondragenter={Callback::from(|event: DragEvent| { - event.prevent_default(); + Msg::Files(event.data_transfer().unwrap().files()) })} + ondragover={&noop_drag} + ondragenter={&noop_drag} >

{"Drop your images here or click to select"}

@@ -101,7 +93,7 @@ impl Component for App { multiple={true} onchange={ctx.link().callback(move |e: Event| { let input: HtmlInputElement = e.target_unchecked_into(); - Self::upload_files(input.files()) + Msg::Files(input.files()) })} />
@@ -114,37 +106,24 @@ impl Component for App { impl App { fn view_file(file: &FileDetails) -> Html { + let file_type = file.file_type.to_string(); + let src = format!("data:{};base64,{}", file_type, STANDARD.encode(&file.data)); html! {
-

{ format!("{}", file.name) }

+

{ &file.name }

if file.file_type.contains("image") { - + } else if file.file_type.contains("video") { }
} } - - fn upload_files(files: Option) -> Msg { - let mut result = Vec::new(); - - if let Some(files) = files { - let files = js_sys::try_iter(&files) - .unwrap() - .unwrap() - .map(|v| web_sys::File::from(v.unwrap())) - .map(File::from); - result.extend(files); - } - Msg::Files(result) - } } - fn main() { yew::Renderer::::new().render(); } diff --git a/examples/node_refs/README.md b/examples/node_refs/README.md index 204a9b86296..379a8a87eef 100644 --- a/examples/node_refs/README.md +++ b/examples/node_refs/README.md @@ -6,7 +6,7 @@ This example shows two input fields which are automatically focused when hovered ## Concepts -The example uses [Refs](https://yew.rs/docs/concepts/components/refs/) to +The example uses [Refs](https://yew.rs/docs/concepts/function-components/node-refs) to manipulate the underlying DOM element directly. ## Running diff --git a/examples/ssr_router/src/bin/ssr_router_server.rs b/examples/ssr_router/src/bin/ssr_router_server.rs index 5a25266302a..95819169754 100644 --- a/examples/ssr_router/src/bin/ssr_router_server.rs +++ b/examples/ssr_router/src/bin/ssr_router_server.rs @@ -40,7 +40,7 @@ async fn render( Query(queries): Query>, State((index_html_before, index_html_after)): State<(String, String)>, ) -> impl IntoResponse { - let url = url.to_string(); + let url = url.path().to_owned(); let renderer = yew::ServerRenderer::::with_props(move || ServerAppProps { url: url.into(), diff --git a/packages/yew-macro/src/html_tree/html_node.rs b/packages/yew-macro/src/html_tree/html_node.rs index 6b8022ba11a..06be31ccc46 100644 --- a/packages/yew-macro/src/html_tree/html_node.rs +++ b/packages/yew-macro/src/html_tree/html_node.rs @@ -17,9 +17,19 @@ pub enum HtmlNode { impl Parse for HtmlNode { fn parse(input: ParseStream) -> Result { let node = if HtmlNode::peek(input.cursor()).is_some() { - let lit: Lit = input.parse()?; - if matches!(lit, Lit::ByteStr(_) | Lit::Byte(_) | Lit::Verbatim(_)) { - return Err(syn::Error::new(lit.span(), "unsupported type")); + let lit = input.parse()?; + match lit { + Lit::ByteStr(lit) => { + return Err(syn::Error::new( + lit.span(), + "byte-strings can't be converted to HTML text + note: remove the `b` prefix or convert this to a `String`", + )) + } + Lit::Verbatim(lit) => { + return Err(syn::Error::new(lit.span(), "unsupported literal")) + } + _ => (), } HtmlNode::Literal(Box::new(lit)) } else { diff --git a/packages/yew-macro/src/stringify.rs b/packages/yew-macro/src/stringify.rs index 4ea9d6483c4..75764266841 100644 --- a/packages/yew-macro/src/stringify.rs +++ b/packages/yew-macro/src/stringify.rs @@ -1,3 +1,6 @@ +use std::borrow::Cow; +use std::mem::size_of; + use proc_macro2::{Span, TokenStream}; use quote::{quote_spanned, ToTokens}; use syn::spanned::Spanned; @@ -75,15 +78,19 @@ impl Stringify for LitStr { } } } + impl Stringify for Lit { fn try_into_lit(&self) -> Option { - let s = match self { + let mut buf = [0; size_of::()]; + let s: Cow<'_, str> = match self { Lit::Str(v) => return v.try_into_lit(), - Lit::Char(v) => v.value().to_string(), - Lit::Int(v) => v.base10_digits().to_string(), - Lit::Float(v) => v.base10_digits().to_string(), - Lit::Bool(_) | Lit::ByteStr(_) | Lit::Byte(_) | Lit::Verbatim(_) => return None, - _ => unreachable!("unknown Lit"), + Lit::Char(v) => (&*v.value().encode_utf8(&mut buf)).into(), + Lit::Int(v) => v.base10_digits().into(), + Lit::Float(v) => v.base10_digits().into(), + Lit::Bool(v) => if v.value() { "true" } else { "false" }.into(), + Lit::Byte(v) => v.value().to_string().into(), + Lit::Verbatim(_) | Lit::ByteStr(_) => return None, + _ => unreachable!("unknown Lit {:?}", self), }; Some(LitStr::new(&s, self.span())) } @@ -91,10 +98,10 @@ impl Stringify for Lit { fn stringify(&self) -> TokenStream { self.try_into_lit() .as_ref() - .map(Stringify::stringify) - .unwrap_or_else(|| stringify_at_runtime(self)) + .map_or_else(|| stringify_at_runtime(self), Stringify::stringify) } } + impl Stringify for Expr { fn try_into_lit(&self) -> Option { if let Expr::Lit(v) = self { @@ -107,7 +114,6 @@ impl Stringify for Expr { fn stringify(&self) -> TokenStream { self.try_into_lit() .as_ref() - .map(Stringify::stringify) - .unwrap_or_else(|| stringify_at_runtime(self)) + .map_or_else(|| stringify_at_runtime(self), Stringify::stringify) } } diff --git a/packages/yew-macro/tests/html_macro/node-fail.stderr b/packages/yew-macro/tests/html_macro/node-fail.stderr index fc94b419875..05676a5c9be 100644 --- a/packages/yew-macro/tests/html_macro/node-fail.stderr +++ b/packages/yew-macro/tests/html_macro/node-fail.stderr @@ -10,25 +10,15 @@ error: unexpected token, expected `}` 5 | html! { { "valid" "invalid" } }; | ^^^^^^^^^ -error: unsupported type - --> tests/html_macro/node-fail.rs:10:14 - | -10 | html! { b'a' }; - | ^^^^ - -error: unsupported type +error: byte-strings can't be converted to HTML text + note: remove the `b` prefix or convert this to a `String` --> tests/html_macro/node-fail.rs:11:14 | 11 | html! { b"str" }; | ^^^^^^ -error: unsupported type - --> tests/html_macro/node-fail.rs:12:22 - | -12 | html! { { b'a' } }; - | ^^^^ - -error: unsupported type +error: byte-strings can't be converted to HTML text + note: remove the `b` prefix or convert this to a `String` --> tests/html_macro/node-fail.rs:13:22 | 13 | html! { { b"str" } }; diff --git a/packages/yew-macro/tests/html_macro/node-pass.rs b/packages/yew-macro/tests/html_macro/node-pass.rs index ed38c3e77cf..38aad870ad3 100644 --- a/packages/yew-macro/tests/html_macro/node-pass.rs +++ b/packages/yew-macro/tests/html_macro/node-pass.rs @@ -37,12 +37,12 @@ pub struct u8; pub struct usize; fn main() { - _ = ::yew::html! { "" }; + _ = ::yew::html! { b'b' }; _ = ::yew::html! { 'a' }; _ = ::yew::html! { "hello" }; - _ = ::yew::html! { "42" }; - _ = ::yew::html! { "1.234" }; - _ = ::yew::html! { "true" }; + _ = ::yew::html! { 42 }; + _ = ::yew::html! { 1.234 }; + _ = ::yew::html! { true }; _ = ::yew::html! { { "" } }; _ = ::yew::html! { { 'a' } }; diff --git a/packages/yew-router/src/navigator.rs b/packages/yew-router/src/navigator.rs index 48345609f83..dec228821b8 100644 --- a/packages/yew-router/src/navigator.rs +++ b/packages/yew-router/src/navigator.rs @@ -159,7 +159,7 @@ impl Navigator { pub(crate) fn prefix_basename<'a>(&self, route_s: &'a str) -> Cow<'a, str> { match self.basename() { Some(base) => { - if route_s.is_empty() && route_s.is_empty() { + if base.is_empty() && route_s.is_empty() { Cow::from("/") } else { Cow::from(format!("{base}{route_s}")) diff --git a/packages/yew-router/src/router.rs b/packages/yew-router/src/router.rs index b3becda9ea1..0c4b4dedf4c 100644 --- a/packages/yew-router/src/router.rs +++ b/packages/yew-router/src/router.rs @@ -1,6 +1,8 @@ //! Router Component. +use std::borrow::Cow; use std::rc::Rc; +use gloo::history::query::Raw; use yew::prelude::*; use yew::virtual_dom::AttrValue; @@ -72,16 +74,43 @@ fn base_router(props: &RouterProps) -> Html { basename, } = props.clone(); + let basename = basename.map(|m| strip_slash_suffix(&m).to_owned()); + let navigator = Navigator::new(history.clone(), basename.clone()); + + let old_basename = use_mut_ref(|| Option::::None); + let mut old_basename = old_basename.borrow_mut(); + if basename != *old_basename { + // If `old_basename` is `Some`, path is probably prefixed with `old_basename`. + // If `old_basename` is `None`, path may or may not be prefixed with the new `basename`, + // depending on whether this is the first render. + let old_navigator = Navigator::new( + history.clone(), + old_basename.as_ref().or(basename.as_ref()).cloned(), + ); + *old_basename = basename.clone(); + let location = history.location(); + let stripped = old_navigator.strip_basename(Cow::from(location.path())); + let prefixed = navigator.prefix_basename(&stripped); + + if prefixed != location.path() { + history + .replace_with_query(prefixed, Raw(location.query_str())) + .unwrap_or_else(|never| match never {}); + } else { + // Reaching here is possible if the page loads with the correct path, including the + // initial basename. In that case, the new basename would be stripped and then + // prefixed right back. While replacing the history would probably be harmless, + // we might as well avoid doing it. + } + } + + let navi_ctx = NavigatorContext { navigator }; + let loc_ctx = use_reducer(|| LocationContext { location: history.location(), ctr: 0, }); - let basename = basename.map(|m| strip_slash_suffix(&m).to_string()); - let navi_ctx = NavigatorContext { - navigator: Navigator::new(history.clone(), basename), - }; - { let loc_ctx_dispatcher = loc_ctx.dispatcher(); diff --git a/packages/yew-router/tests/link.rs b/packages/yew-router/tests/link.rs index 56aa30493b1..7e195d9246e 100644 --- a/packages/yew-router/tests/link.rs +++ b/packages/yew-router/tests/link.rs @@ -1,7 +1,10 @@ #![cfg(not(target_os = "wasi"))] +use std::sync::atomic::{AtomicU8, Ordering}; use std::time::Duration; +use gloo::utils::window; +use js_sys::{JsString, Object, Reflect}; use serde::{Deserialize, Serialize}; use wasm_bindgen_test::{wasm_bindgen_test as test, wasm_bindgen_test_configure}; use yew::functional::function_component; @@ -49,8 +52,20 @@ enum Routes { Search, } +#[derive(PartialEq, Properties)] +struct NavigationMenuProps { + #[prop_or(None)] + assertion: Option, +} + #[function_component(NavigationMenu)] -fn navigation_menu() -> Html { +fn navigation_menu(props: &NavigationMenuProps) -> Html { + let navigator = use_navigator().unwrap(); + let location = use_location().unwrap(); + if let Some(assertion) = props.assertion { + assertion(&navigator, &location); + } + html! {
  • @@ -91,12 +106,11 @@ fn root_for_browser_router() -> Html { } } -#[test] async fn link_in_browser_router() { let div = gloo::utils::document().create_element("div").unwrap(); let _ = div.set_attribute("id", "browser-router"); let _ = gloo::utils::body().append_child(&div); - yew::Renderer::::with_root(div).render(); + let handle = yew::Renderer::::with_root(div).render(); sleep(Duration::ZERO).await; @@ -115,26 +129,77 @@ async fn link_in_browser_router() { "/search?q=Rust&lang=en_US", link_href("#browser-router ul > li.search-q-lang > a") ); + + handle.destroy(); +} + +#[derive(PartialEq, Properties)] +struct BasenameProps { + basename: Option, + assertion: fn(&Navigator, &Location), } #[function_component(RootForBasename)] -fn root_for_basename() -> Html { +fn root_for_basename(props: &BasenameProps) -> Html { html! { - - + + } } -#[test] -async fn link_with_basename() { +async fn link_with_basename(correct_initial_path: bool) { + if correct_initial_path { + let cookie = Object::new(); + Reflect::set(&cookie, &JsString::from("foo"), &JsString::from("bar")).unwrap(); + window() + .history() + .unwrap() + .replace_state_with_url(&cookie, "", Some("/base/")) + .unwrap(); + } + + static RENDERS: AtomicU8 = AtomicU8::new(0); + RENDERS.store(0, Ordering::Relaxed); + let div = gloo::utils::document().create_element("div").unwrap(); let _ = div.set_attribute("id", "with-basename"); let _ = gloo::utils::body().append_child(&div); - yew::Renderer::::with_root(div).render(); + + let mut handle = yew::Renderer::::with_root_and_props( + div, + BasenameProps { + basename: Some("/base/".to_owned()), + assertion: |navigator, location| { + RENDERS.fetch_add(1, Ordering::Relaxed); + assert_eq!(navigator.basename(), Some("/base")); + assert_eq!(location.path(), "/base/"); + }, + }, + ) + .render(); sleep(Duration::ZERO).await; + if correct_initial_path { + // If the initial path was correct, the router shouldn't have mutated the history. + assert_eq!( + Reflect::get( + &window().history().unwrap().state().unwrap(), + &JsString::from("foo") + ) + .unwrap() + .as_string() + .as_deref(), + Some("bar") + ); + } + + assert_eq!( + "/base/", + gloo::utils::window().location().pathname().unwrap() + ); + assert_eq!("/base/posts", link_href("#with-basename ul > li.posts > a")); assert_eq!( "/base/posts?page=2", @@ -153,6 +218,68 @@ async fn link_with_basename() { "/base/search?q=Rust&lang=en_US", link_href("#with-basename ul > li.search-q-lang > a") ); + + // Some(a) -> Some(b) + handle.update(BasenameProps { + basename: Some("/bayes/".to_owned()), + assertion: |navigator, location| { + RENDERS.fetch_add(1, Ordering::Relaxed); + assert_eq!(navigator.basename(), Some("/bayes")); + assert_eq!(location.path(), "/bayes/"); + }, + }); + + sleep(Duration::ZERO).await; + + assert_eq!( + "/bayes/", + gloo::utils::window().location().pathname().unwrap() + ); + + assert_eq!( + "/bayes/posts", + link_href("#with-basename ul > li.posts > a") + ); + + // Some -> None + handle.update(BasenameProps { + basename: None, + assertion: |navigator, location| { + RENDERS.fetch_add(1, Ordering::Relaxed); + assert_eq!(navigator.basename(), None); + assert_eq!(location.path(), "/"); + }, + }); + + sleep(Duration::ZERO).await; + + assert_eq!("/", gloo::utils::window().location().pathname().unwrap()); + + assert_eq!("/posts", link_href("#with-basename ul > li.posts > a")); + + // None -> Some + handle.update(BasenameProps { + basename: Some("/bass/".to_string()), + assertion: |navigator, location| { + RENDERS.fetch_add(1, Ordering::Relaxed); + assert_eq!(navigator.basename(), Some("/bass")); + assert_eq!(location.path(), "/bass/"); + }, + }); + + sleep(Duration::ZERO).await; + + assert_eq!( + "/bass/", + gloo::utils::window().location().pathname().unwrap() + ); + + assert_eq!("/bass/posts", link_href("#with-basename ul > li.posts > a")); + + handle.destroy(); + + // 1 initial, 1 rerender after initial, 3 props changes + assert_eq!(RENDERS.load(Ordering::Relaxed), 5); } #[function_component(RootForHashRouter)] @@ -164,12 +291,11 @@ fn root_for_hash_router() -> Html { } } -#[test] async fn link_in_hash_router() { let div = gloo::utils::document().create_element("div").unwrap(); let _ = div.set_attribute("id", "hash-router"); let _ = gloo::utils::body().append_child(&div); - yew::Renderer::::with_root(div).render(); + let handle = yew::Renderer::::with_root(div).render(); sleep(Duration::ZERO).await; @@ -188,4 +314,15 @@ async fn link_in_hash_router() { "#/search?q=Rust&lang=en_US", link_href("#hash-router ul > li.search-q-lang > a") ); + + handle.destroy(); +} + +// These cannot be run in concurrently because they all read/write the URL. +#[test] +async fn sequential_tests() { + link_in_hash_router().await; + link_in_browser_router().await; + link_with_basename(false).await; + link_with_basename(true).await; } diff --git a/packages/yew/src/suspense/hooks.rs b/packages/yew/src/suspense/hooks.rs index 3d975e346fd..488f1d487a1 100644 --- a/packages/yew/src/suspense/hooks.rs +++ b/packages/yew/src/suspense/hooks.rs @@ -15,6 +15,14 @@ pub struct UseFutureHandle { inner: UseStateHandle>, } +impl Clone for UseFutureHandle { + fn clone(&self) -> Self { + Self { + inner: self.inner.clone(), + } + } +} + impl Deref for UseFutureHandle { type Target = O; diff --git a/website/docs/getting-started/editor-setup.mdx b/website/docs/getting-started/editor-setup.mdx index dc0fbf6f634..de6c52d01b2 100644 --- a/website/docs/getting-started/editor-setup.mdx +++ b/website/docs/getting-started/editor-setup.mdx @@ -130,7 +130,7 @@ Utilities like Rename, Go to Declaration, Find Usages will work inside the macro Rust-Yew extension is [available on VSC Marketplace](https://marketplace.visualstudio.com/items?itemName=TechTheAwesome.rust-yew), providing syntax highlight, renames, hover, and more. -Emmet support should work out of the box, if not please fall back to edditing the `settings.json` file: +Emmet support should work out of the box, if not please fall back to editing the `settings.json` file: ```json "emmet.includeLanguages": { diff --git a/website/docs/more/debugging.mdx b/website/docs/more/debugging.mdx index b4b65d805f6..999ca0a710c 100644 --- a/website/docs/more/debugging.mdx +++ b/website/docs/more/debugging.mdx @@ -45,7 +45,7 @@ fn main() { `tracing-web` can be used with [`tracing-subscriber`](https://crates.io/crates/tracing-subscriber) to output messages to the browser console. -```rust, ignore +```rust ,ignore use tracing_subscriber::{ fmt::{ format::{FmtSpan, Pretty}, diff --git a/website/versioned_docs/version-0.21/getting-started/editor-setup.mdx b/website/versioned_docs/version-0.21/getting-started/editor-setup.mdx index 241d12e5f60..9a2d2ae149a 100644 --- a/website/versioned_docs/version-0.21/getting-started/editor-setup.mdx +++ b/website/versioned_docs/version-0.21/getting-started/editor-setup.mdx @@ -130,7 +130,7 @@ Utilities like Rename, Go to Declaration, Find Usages will work inside the macro Rust-Yew extension is [avaliable on VSC Marketplace](https://marketplace.visualstudio.com/items?itemName=TechTheAwesome.rust-yew), providing syntax highlight, renames, hover, and more. -Emmet support should work out of the box, if not please fall back to edditing the `settings.json` file: +Emmet support should work out of the box, if not please fall back to editing the `settings.json` file: ```json "emmet.includeLanguages": {