From 08da9a125379b62b6cf207cc004c1079859a24e7 Mon Sep 17 00:00:00 2001 From: Evan Almloff Date: Thu, 25 Apr 2024 13:30:25 -0500 Subject: [PATCH] Implement hash fragments in the router (#2320) * implement hash fragments in the router * clean up query and search example --------- Co-authored-by: Jonathan Kelley --- Cargo.lock | 2 + Cargo.toml | 7 +- examples/hash_fragment_state.rs | 130 ++++++++++++++++++++++++ examples/query_segment_search.rs | 117 +++++++++++++++++++++ examples/query_segments.rs | 85 ---------------- packages/core/src/nodes.rs | 4 +- packages/router-macro/src/hash.rs | 62 +++++++++++ packages/router-macro/src/lib.rs | 9 +- packages/router-macro/src/query.rs | 70 ++++++++++++- packages/router-macro/src/redirect.rs | 12 ++- packages/router-macro/src/route.rs | 21 +++- packages/router-macro/src/route_tree.rs | 7 ++ packages/router-macro/src/segment.rs | 101 +++++++----------- packages/router/src/contexts/router.rs | 1 - packages/router/src/history/web.rs | 3 +- packages/router/src/routable.rs | 106 ++++++++++++++----- packages/router/src/router_cfg.rs | 3 - 17 files changed, 550 insertions(+), 190 deletions(-) create mode 100644 examples/hash_fragment_state.rs create mode 100644 examples/query_segment_search.rs delete mode 100644 examples/query_segments.rs create mode 100644 packages/router-macro/src/hash.rs diff --git a/Cargo.lock b/Cargo.lock index 3b4a4421e..68f64b523 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2335,6 +2335,8 @@ dependencies = [ name = "dioxus-examples" version = "0.5.2" dependencies = [ + "base64 0.21.7", + "ciborium", "dioxus", "dioxus-ssr", "form_urlencoded", diff --git a/Cargo.toml b/Cargo.toml index 12424cfe4..a9f93939a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -157,6 +157,8 @@ publish = false manganis = { workspace = true, optional = true } reqwest = { version = "0.11.9", features = ["json"], optional = true } http-range = { version = "0.1.5", optional = true } +ciborium = { version = "0.2.1", optional = true } +base64 = { version = "0.21.0", optional = true } [dev-dependencies] dioxus = { workspace = true, features = ["router"] } @@ -193,7 +195,6 @@ web = ["dioxus/web"] collect-assets = ["manganis"] http = ["reqwest", "http-range"] - [[example]] name = "login_form" required-features = ["http"] @@ -217,3 +218,7 @@ required-features = ["http"] [[example]] name = "image_generator_openai" required-features = ["http"] + +[[example]] +name = "hash_fragment_state" +required-features = ["ciborium", "base64"] diff --git a/examples/hash_fragment_state.rs b/examples/hash_fragment_state.rs new file mode 100644 index 000000000..ed77c8c46 --- /dev/null +++ b/examples/hash_fragment_state.rs @@ -0,0 +1,130 @@ +//! This example shows how to use the hash segment to store state in the url. +//! +//! You can set up two way data binding between the url hash and signals. +//! +//! Run this example on desktop with +//! ```sh +//! dx serve --example hash_fragment_state --features=ciborium,base64 +//! ``` +//! Or on web with +//! ```sh +//! dx serve --platform web --features web --example hash_fragment_state --features=ciborium,base64 -- --no-default-features +//! ``` + +use std::{fmt::Display, str::FromStr}; + +use base64::engine::general_purpose::STANDARD; +use base64::Engine; +use dioxus::prelude::*; +use serde::{Deserialize, Serialize}; + +fn main() { + launch(|| { + rsx! { + Router:: {} + } + }); +} + +#[derive(Routable, Clone, Debug, PartialEq)] +#[rustfmt::skip] +enum Route { + #[route("/#:url_hash")] + Home { + url_hash: State, + }, +} + +// You can use a custom type with the hash segment as long as it implements Display, FromStr and Default +#[derive(Serialize, Deserialize, Clone, Debug, Default, PartialEq)] +struct State { + counters: Vec, +} + +// Display the state in a way that can be parsed by FromStr +impl Display for State { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let mut serialized = Vec::new(); + if ciborium::into_writer(self, &mut serialized).is_ok() { + write!(f, "{}", STANDARD.encode(serialized))?; + } + Ok(()) + } +} + +enum StateParseError { + DecodeError(base64::DecodeError), + CiboriumError(ciborium::de::Error), +} + +impl std::fmt::Display for StateParseError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::DecodeError(err) => write!(f, "Failed to decode base64: {}", err), + Self::CiboriumError(err) => write!(f, "Failed to deserialize: {}", err), + } + } +} + +// Parse the state from a string that was created by Display +impl FromStr for State { + type Err = StateParseError; + + fn from_str(s: &str) -> Result { + let decompressed = STANDARD + .decode(s.as_bytes()) + .map_err(StateParseError::DecodeError)?; + let parsed = ciborium::from_reader(std::io::Cursor::new(decompressed)) + .map_err(StateParseError::CiboriumError)?; + Ok(parsed) + } +} + +#[component] +fn Home(url_hash: ReadOnlySignal) -> Element { + // The initial state of the state comes from the url hash + let mut state = use_signal(&*url_hash); + + // Change the state signal when the url hash changes + use_memo(move || { + if *state.peek() != *url_hash.read() { + state.set(url_hash()); + } + }); + + // Change the url hash when the state changes + use_memo(move || { + if *state.read() != *url_hash.peek() { + navigator().replace(Route::Home { url_hash: state() }); + } + }); + + rsx! { + button { + onclick: move |_| state.write().counters.clear(), + "Reset" + } + button { + onclick: move |_| { + state.write().counters.push(0); + }, + "Add Counter" + } + for counter in 0..state.read().counters.len() { + div { + button { + onclick: move |_| { + state.write().counters.remove(counter); + }, + "Remove" + } + button { + onclick: move |_| { + state.write().counters[counter] += 1; + }, + "Count: {state.read().counters[counter]}" + } + } + } + } +} diff --git a/examples/query_segment_search.rs b/examples/query_segment_search.rs new file mode 100644 index 000000000..018f475e4 --- /dev/null +++ b/examples/query_segment_search.rs @@ -0,0 +1,117 @@ +//! This example shows how to access and use query segments present in an url on the web. +//! +//! The enum router makes it easy to use your route as state in your app. This example shows how to use the router to encode search text into the url and decode it back into a string. +//! +//! Run this example on desktop with +//! ```sh +//! dx serve --example query_segment_search +//! ``` +//! Or on web with +//! ```sh +//! dx serve --platform web --features web --example query_segment_search -- --no-default-features +//! ``` + +use dioxus::prelude::*; + +fn main() { + launch(|| { + rsx! { + Router:: {} + } + }); +} + +#[derive(Routable, Clone, Debug, PartialEq)] +#[rustfmt::skip] +enum Route { + #[route("/")] + Home {}, + + // The each query segment must implement and Display. + // You can use multiple query segments separated by `&`s. + #[route("/search?:query&:word_count")] + Search { + query: String, + word_count: usize, + }, +} + +#[component] +fn Home() -> Element { + // Display a list of example searches in the home page + rsx! { + ul { + li { + Link { + to: Route::Search { + query: "hello".to_string(), + word_count: 1 + }, + "Search for results containing 'hello' and at least one word" + } + } + li { + Link { + to: Route::Search { + query: "dioxus".to_string(), + word_count: 2 + }, + "Search for results containing 'dioxus' and at least two word" + } + } + } + } +} + +// Instead of accepting String and usize directly, we use ReadOnlySignal to make the parameters `Copy` and let us subscribe to them automatically inside the meme +#[component] +fn Search(query: ReadOnlySignal, word_count: ReadOnlySignal) -> Element { + const ITEMS: &[&str] = &[ + "hello", + "world", + "hello world", + "hello dioxus", + "hello dioxus-router", + ]; + + // Find all results that contain the query and the right number of words + // This memo will automatically rerun when the query or word count changes because we read the signals inside the closure + let results = use_memo(move || { + ITEMS + .iter() + .filter(|item| { + item.contains(&*query.read()) && item.split_whitespace().count() >= word_count() + }) + .collect::>() + }); + + rsx! { + h1 { "Search for {query}" } + input { + oninput: move |e| { + // Every time the query changes, we change the current route to the new query + navigator().replace(Route::Search { + query: e.value(), + word_count: word_count(), + }); + }, + value: "{query}", + } + input { + r#type: "number", + oninput: move |e| { + // Every time the word count changes, we change the current route to the new query + if let Ok(word_count) = e.value().parse() { + navigator().replace(Route::Search { + query: query(), + word_count, + }); + } + }, + value: "{word_count}", + } + for result in results.read().iter() { + div { "{result}" } + } + } +} diff --git a/examples/query_segments.rs b/examples/query_segments.rs deleted file mode 100644 index 3f7542970..000000000 --- a/examples/query_segments.rs +++ /dev/null @@ -1,85 +0,0 @@ -//! Example: Url query segments usage -//! ------------------------------------ -//! -//! This example shows how to access and use multiple query segments present in an url on the web. -//! -//! Run `dx serve` and navigate to `http://localhost:8080/blog?name=John&surname=Doe` -use dioxus::prelude::*; -use std::fmt::Display; - -#[derive(Routable, Clone)] -#[rustfmt::skip] -enum Route { - // segments that start with ?:.. are query segments that capture the entire query - #[route("/blog?:..query_params")] - BlogPost { - // You must include query segments in child variants - query_params: ManualBlogQuerySegments, - }, - - // segments that follow the ?:field&:other_field syntax are query segments that follow the standard url query syntax - #[route("/autoblog?:name&:surname")] - AutomaticBlogPost { - name: String, - surname: String, - }, -} - -#[derive(Debug, Clone, PartialEq)] -struct ManualBlogQuerySegments { - name: String, - surname: String, -} - -/// The display impl needs to display the query in a way that can be parsed: -impl Display for ManualBlogQuerySegments { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!(f, "name={}&surname={}", self.name, self.surname) - } -} - -/// The query segment is anything that implements . You can implement that trait for a struct if you want to parse multiple query parameters. -impl FromQuery for ManualBlogQuerySegments { - fn from_query(query: &str) -> Self { - let mut name = None; - let mut surname = None; - let pairs = form_urlencoded::parse(query.as_bytes()); - pairs.for_each(|(key, value)| { - if key == "name" { - name = Some(value.clone().into()); - } - if key == "surname" { - surname = Some(value.clone().into()); - } - }); - Self { - name: name.unwrap(), - surname: surname.unwrap(), - } - } -} - -#[component] -fn BlogPost(query_params: ManualBlogQuerySegments) -> Element { - rsx! { - div { "This is your blogpost with a query segment:" } - div { "{query_params:?}" } - } -} - -#[component] -fn AutomaticBlogPost(name: String, surname: String) -> Element { - rsx! { - div { "This is your blogpost with a query segment:" } - div { "name={name}&surname={surname}" } - } -} - -#[component] -fn App() -> Element { - rsx! { Router:: {} } -} - -fn main() { - launch(App); -} diff --git a/packages/core/src/nodes.rs b/packages/core/src/nodes.rs index 803f05b41..f6a5d6919 100644 --- a/packages/core/src/nodes.rs +++ b/packages/core/src/nodes.rs @@ -392,9 +392,7 @@ where } #[cfg(feature = "serialize")] -fn deserialize_leaky<'a, 'de, T: serde::Deserialize<'de>, D>( - deserializer: D, -) -> Result<&'a [T], D::Error> +fn deserialize_leaky<'a, 'de, T, D>(deserializer: D) -> Result<&'a [T], D::Error> where T: serde::Deserialize<'de>, D: serde::Deserializer<'de>, diff --git a/packages/router-macro/src/hash.rs b/packages/router-macro/src/hash.rs new file mode 100644 index 000000000..2a16bc4e0 --- /dev/null +++ b/packages/router-macro/src/hash.rs @@ -0,0 +1,62 @@ +use quote::quote; +use syn::{Ident, Type}; + +use proc_macro2::TokenStream as TokenStream2; + +#[derive(Debug)] +pub struct HashFragment { + pub ident: Ident, + pub ty: Type, +} + +impl HashFragment { + pub fn contains_ident(&self, ident: &Ident) -> bool { + self.ident == *ident + } + + pub fn parse(&self) -> TokenStream2 { + let ident = &self.ident; + let ty = &self.ty; + quote! { + let #ident = <#ty as dioxus_router::routable::FromHashFragment>::from_hash_fragment(&*hash); + } + } + + pub fn write(&self) -> TokenStream2 { + let ident = &self.ident; + quote! { + write!(f, "#{}", #ident)?; + } + } + + pub fn parse_from_str<'a>( + route_span: proc_macro2::Span, + mut fields: impl Iterator, + hash: &str, + ) -> syn::Result { + // check if the route has a hash string + let Some(hash) = hash.strip_prefix(':') else { + return Err(syn::Error::new( + route_span, + "Failed to parse `:`. Hash fragments must be in the format '#:'", + )); + }; + + let hash_ident = Ident::new(hash, proc_macro2::Span::call_site()); + let field = fields.find(|(name, _)| *name == &hash_ident); + + let ty = if let Some((_, ty)) = field { + ty.clone() + } else { + return Err(syn::Error::new( + route_span, + format!("Could not find a field with the name '{}'", hash_ident), + )); + }; + + Ok(Self { + ident: hash_ident, + ty, + }) + } +} diff --git a/packages/router-macro/src/lib.rs b/packages/router-macro/src/lib.rs index 5d9325c1f..d5f908e5e 100644 --- a/packages/router-macro/src/lib.rs +++ b/packages/router-macro/src/lib.rs @@ -16,6 +16,7 @@ use proc_macro2::TokenStream as TokenStream2; use crate::{layout::LayoutId, route_tree::RouteTree}; +mod hash; mod layout; mod nest; mod query; @@ -523,6 +524,11 @@ impl RouteEnum { from_route = true } } + if let Some(hash) = &route.hash { + if hash.contains_ident(field) { + from_route = true + } + } } } } @@ -576,9 +582,10 @@ impl RouteEnum { fn from_str(s: &str) -> Result { let route = s; - let (route, _hash) = route.split_once('#').unwrap_or((route, "")); + let (route, hash) = route.split_once('#').unwrap_or((route, "")); let (route, query) = route.split_once('?').unwrap_or((route, "")); let query = dioxus_router::exports::urlencoding::decode(query).unwrap_or(query.into()); + let hash = dioxus_router::exports::urlencoding::decode(hash).unwrap_or(hash.into()); let mut segments = route.split('/').map(|s| dioxus_router::exports::urlencoding::decode(s).unwrap_or(s.into())); // skip the first empty segment if s.starts_with('/') { diff --git a/packages/router-macro/src/query.rs b/packages/router-macro/src/query.rs index 40af2b39a..7cddf5c57 100644 --- a/packages/router-macro/src/query.rs +++ b/packages/router-macro/src/query.rs @@ -51,6 +51,66 @@ impl QuerySegment { } } } + + pub fn parse_from_str<'a>( + route_span: proc_macro2::Span, + mut fields: impl Iterator, + query: &str, + ) -> syn::Result { + // check if the route has a query string + if let Some(query) = query.strip_prefix(":..") { + let query_ident = Ident::new(query, proc_macro2::Span::call_site()); + let field = fields.find(|(name, _)| *name == &query_ident); + + let ty = if let Some((_, ty)) = field { + ty.clone() + } else { + return Err(syn::Error::new( + route_span, + format!("Could not find a field with the name '{}'", query_ident), + )); + }; + + Ok(QuerySegment::Single(FullQuerySegment { + ident: query_ident, + ty, + })) + } else { + let mut query_arguments = Vec::new(); + for segment in query.split('&') { + if segment.is_empty() { + return Err(syn::Error::new( + route_span, + "Query segments should be non-empty", + )); + } + if let Some(query_argument) = segment.strip_prefix(':') { + let query_ident = Ident::new(query_argument, proc_macro2::Span::call_site()); + let field = fields.find(|(name, _)| *name == &query_ident); + + let ty = if let Some((_, ty)) = field { + ty.clone() + } else { + return Err(syn::Error::new( + route_span, + format!("Could not find a field with the name '{}'", query_ident), + )); + }; + + query_arguments.push(QueryArgument { + ident: query_ident, + ty, + }); + } else { + return Err(syn::Error::new( + route_span, + "Query segments should be a : followed by the name of the query argument", + )); + } + } + Ok(QuerySegment::Segments(query_arguments)) + } + } } #[derive(Debug)] @@ -71,7 +131,10 @@ impl FullQuerySegment { pub fn write(&self) -> TokenStream2 { let ident = &self.ident; quote! { - write!(f, "?{}", #ident)?; + { + let as_string = #ident.to_string(); + write!(f, "?{}", dioxus_router::exports::urlencoding::encode(&as_string))?; + } } } } @@ -97,7 +160,10 @@ impl QueryArgument { pub fn write(&self) -> TokenStream2 { let ident = &self.ident; quote! { - write!(f, "{}={}", stringify!(#ident), #ident)?; + { + let as_string = #ident.to_string(); + write!(f, "{}={}", stringify!(#ident), dioxus_router::exports::urlencoding::encode(&as_string))?; + } } } } diff --git a/packages/router-macro/src/redirect.rs b/packages/router-macro/src/redirect.rs index 2933bbc8e..3afefa230 100644 --- a/packages/router-macro/src/redirect.rs +++ b/packages/router-macro/src/redirect.rs @@ -3,6 +3,7 @@ use quote::{format_ident, quote}; use syn::LitStr; use crate::{ + hash::HashFragment, nest::NestId, query::QuerySegment, segment::{create_error_type, parse_route_segments, RouteSegment}, @@ -14,6 +15,7 @@ pub(crate) struct Redirect { pub nests: Vec, pub segments: Vec, pub query: Option, + pub hash: Option, pub function: syn::ExprClosure, pub index: usize, } @@ -40,6 +42,13 @@ impl Redirect { } } + pub fn parse_hash(&self) -> TokenStream { + match &self.hash { + Some(hash) => hash.parse(), + None => quote! {}, + } + } + pub fn parse( input: syn::parse::ParseStream, active_nests: Vec, @@ -73,7 +82,7 @@ impl Redirect { } } - let (segments, query) = parse_route_segments( + let (segments, query, hash) = parse_route_segments( path.span(), #[allow(clippy::map_identity)] closure_arguments.iter().map(|(name, ty)| (name, ty)), @@ -85,6 +94,7 @@ impl Redirect { nests: active_nests, segments, query, + hash, function, index, }) diff --git a/packages/router-macro/src/route.rs b/packages/router-macro/src/route.rs index 7903d9a20..7244d775c 100644 --- a/packages/router-macro/src/route.rs +++ b/packages/router-macro/src/route.rs @@ -9,6 +9,7 @@ use syn::{Ident, LitStr}; use proc_macro2::TokenStream as TokenStream2; +use crate::hash::HashFragment; use crate::layout::Layout; use crate::layout::LayoutId; use crate::nest::Nest; @@ -56,6 +57,7 @@ pub(crate) struct Route { pub route: String, pub segments: Vec, pub query: Option, + pub hash: Option, pub nests: Vec, pub layouts: Vec, fields: Vec<(Ident, Type)>, @@ -144,7 +146,7 @@ impl Route { _ => Vec::new(), }; - let (route_segments, query) = { + let (route_segments, query, hash) = { parse_route_segments( variant.ident.span(), fields.iter().map(|f| (&f.0, &f.1)), @@ -158,6 +160,7 @@ impl Route { segments: route_segments, route, query, + hash, nests, layouts, fields, @@ -167,7 +170,8 @@ impl Route { pub fn display_match(&self, nests: &[Nest]) -> TokenStream2 { let name = &self.route_name; let dynamic_segments = self.dynamic_segments(); - let write_query = self.query.as_ref().map(|q| q.write()); + let write_query: Option = self.query.as_ref().map(|q| q.write()); + let write_hash = self.hash.as_ref().map(|q| q.write()); match &self.ty { RouteType::Child(field) => { @@ -200,6 +204,7 @@ impl Route { #(#write_nests)* #(#write_segments)* #write_query + #write_hash } } } @@ -286,6 +291,11 @@ impl Route { from_route = true } } + if let Some(hash) = &self.hash { + if hash.contains_ident(name) { + from_route = true + } + } if from_route { quote! {#name} @@ -337,6 +347,13 @@ impl Route { None => quote! {}, } } + + pub fn parse_hash(&self) -> TokenStream2 { + match &self.hash { + Some(hash) => hash.parse(), + None => quote! {}, + } + } } #[derive(Debug)] diff --git a/packages/router-macro/src/route_tree.rs b/packages/router-macro/src/route_tree.rs index 27ee762f6..80826aa64 100644 --- a/packages/router-macro/src/route_tree.rs +++ b/packages/router-macro/src/route_tree.rs @@ -337,6 +337,7 @@ impl<'a> RouteTreeSegmentData<'a> { let construct_variant = route.construct(nests, enum_name); let parse_query = route.parse_query(); + let parse_hash = route.parse_hash(); let insure_not_trailing = match route.ty { RouteType::Leaf { .. } => route @@ -356,6 +357,7 @@ impl<'a> RouteTreeSegmentData<'a> { enum_variant, &variant_parse_error, parse_query, + parse_hash, ), &error_enum_name, enum_variant, @@ -426,6 +428,7 @@ impl<'a> RouteTreeSegmentData<'a> { .skip_while(|(_, seg)| matches!(seg, RouteSegment::Static(_))); let parse_query = redirect.parse_query(); + let parse_hash = redirect.parse_hash(); let insure_not_trailing = redirect .segments @@ -454,6 +457,7 @@ impl<'a> RouteTreeSegmentData<'a> { enum_variant, &variant_parse_error, parse_query, + parse_hash, ), &error_enum_name, enum_variant, @@ -501,6 +505,7 @@ fn return_constructed( enum_variant: &Ident, variant_parse_error: &Ident, parse_query: TokenStream, + parse_hash: TokenStream, ) -> TokenStream { if insure_not_trailing { quote! { @@ -514,6 +519,7 @@ fn return_constructed( // This is the last segment, return the parsed route (None, _) | (Some(""), None) => { #parse_query + #parse_hash return Ok(#construct_variant); } _ => { @@ -530,6 +536,7 @@ fn return_constructed( } else { quote! { #parse_query + #parse_hash return Ok(#construct_variant); } } diff --git a/packages/router-macro/src/segment.rs b/packages/router-macro/src/segment.rs index 9b0bcb4be..cba6b0e87 100644 --- a/packages/router-macro/src/segment.rs +++ b/packages/router-macro/src/segment.rs @@ -3,7 +3,7 @@ use syn::{Ident, Type}; use proc_macro2::{Span, TokenStream as TokenStream2}; -use crate::query::{FullQuerySegment, QueryArgument, QuerySegment}; +use crate::{hash::HashFragment, query::QuerySegment}; #[derive(Debug, Clone)] pub enum RouteSegment { @@ -24,7 +24,12 @@ impl RouteSegment { pub fn write_segment(&self) -> TokenStream2 { match self { Self::Static(segment) => quote! { write!(f, "/{}", #segment)?; }, - Self::Dynamic(ident, _) => quote! { write!(f, "/{}", #ident)?; }, + Self::Dynamic(ident, _) => quote! { + { + let as_string = #ident.to_string(); + write!(f, "/{}", dioxus_router::exports::urlencoding::encode(&as_string))?; + } + }, Self::CatchAll(ident, _) => quote! { #ident.display_route_segments(f)?; }, } } @@ -130,15 +135,38 @@ pub fn static_segment_idx(idx: usize) -> Ident { pub fn parse_route_segments<'a>( route_span: Span, - fields: impl Iterator + Clone, + mut fields: impl Iterator + Clone, route: &str, -) -> syn::Result<(Vec, Option)> { +) -> syn::Result<( + Vec, + Option, + Option, +)> { let mut route_segments = Vec::new(); - let (route_string, query) = match route.rsplit_once('?') { - Some((route, query)) => (route, Some(query)), + let (route_string, hash) = match route.rsplit_once('#') { + Some((route, hash)) => ( + route, + Some(HashFragment::parse_from_str( + route_span, + fields.clone(), + hash, + )?), + ), None => (route, None), }; + + let (route_string, query) = match route_string.rsplit_once('?') { + Some((route, query)) => ( + route, + Some(QuerySegment::parse_from_str( + route_span, + fields.clone(), + query, + )?), + ), + None => (route_string, None), + }; let mut iterator = route_string.split('/'); // skip the first empty segment @@ -198,66 +226,7 @@ pub fn parse_route_segments<'a>( } } - // check if the route has a query string - let parsed_query = match query { - Some(query) => { - if let Some(query) = query.strip_prefix(":..") { - let query_ident = Ident::new(query, Span::call_site()); - let field = fields.clone().find(|(name, _)| *name == &query_ident); - - let ty = if let Some((_, ty)) = field { - ty.clone() - } else { - return Err(syn::Error::new( - route_span, - format!("Could not find a field with the name '{}'", query_ident), - )); - }; - - Some(QuerySegment::Single(FullQuerySegment { - ident: query_ident, - ty, - })) - } else { - let mut query_arguments = Vec::new(); - for segment in query.split('&') { - if segment.is_empty() { - return Err(syn::Error::new( - route_span, - "Query segments should be non-empty", - )); - } - if let Some(query_argument) = segment.strip_prefix(':') { - let query_ident = Ident::new(query_argument, Span::call_site()); - let field = fields.clone().find(|(name, _)| *name == &query_ident); - - let ty = if let Some((_, ty)) = field { - ty.clone() - } else { - return Err(syn::Error::new( - route_span, - format!("Could not find a field with the name '{}'", query_ident), - )); - }; - - query_arguments.push(QueryArgument { - ident: query_ident, - ty, - }); - } else { - return Err(syn::Error::new( - route_span, - "Query segments should be a : followed by the name of the query argument", - )); - } - } - Some(QuerySegment::Segments(query_arguments)) - } - } - None => None, - }; - - Ok((route_segments, parsed_query)) + Ok((route_segments, query, hash)) } pub(crate) fn create_error_type( diff --git a/packages/router/src/contexts/router.rs b/packages/router/src/contexts/router.rs index 85f5ac9ed..b49c2a53b 100644 --- a/packages/router/src/contexts/router.rs +++ b/packages/router/src/contexts/router.rs @@ -76,7 +76,6 @@ impl RouterContext { mark_dirty: Arc, ) -> Self where - R: Clone, ::Err: std::fmt::Display, { let subscriber_update = mark_dirty.clone(); diff --git a/packages/router/src/history/web.rs b/packages/router/src/history/web.rs index 180840d2d..57e577b3b 100644 --- a/packages/router/src/history/web.rs +++ b/packages/router/src/history/web.rs @@ -128,7 +128,8 @@ where fn route_from_location(&self) -> R { let location = self.window.location(); let path = location.pathname().unwrap_or_else(|_| "/".into()) - + &location.search().unwrap_or("".into()); + + &location.search().unwrap_or("".into()) + + &location.hash().unwrap_or("".into()); let path = match self.prefix { None => path, Some(ref prefix) => { diff --git a/packages/router/src/routable.rs b/packages/router/src/routable.rs index 06e3a9351..47d86d0a0 100644 --- a/packages/router/src/routable.rs +++ b/packages/router/src/routable.rs @@ -36,7 +36,7 @@ pub trait FromQuery { impl From<&'a str>> FromQuery for T { fn from_query(query: &str) -> Self { - T::from(&*urlencoding::decode(query).expect("Failed to decode url encoding")) + T::from(query) } } @@ -58,14 +58,7 @@ where type Err = ::Err; fn from_query_argument(argument: &str) -> Result { - let result = match urlencoding::decode(argument) { - Ok(argument) => T::from_str(&argument), - Err(err) => { - tracing::error!("Failed to decode url encoding: {}", err); - T::from_str(argument) - } - }; - match result { + match T::from_str(argument) { Ok(result) => Ok(result), Err(err) => { tracing::error!("Failed to parse query argument: {}", err); @@ -75,6 +68,82 @@ where } } +/// Something that can be created from an entire hash fragment. +/// +/// This trait needs to be implemented if you want to turn a hash fragment into a struct. +/// +/// # Example +/// +/// ```rust +/// use dioxus::prelude::*; +/// +/// #[derive(Routable, Clone)] +/// #[rustfmt::skip] +/// enum Route { +/// // State is stored in the url hash +/// #[route("/#:url_hash")] +/// Home { +/// url_hash: State, +/// }, +/// } +/// +/// #[component] +/// fn Home(url_hash: State) -> Element { +/// todo!() +/// } +/// +/// +/// #[derive(Clone, PartialEq, Default)] +/// struct State { +/// count: usize, +/// other_count: usize +/// } +/// +/// // The hash segment will be displayed as a string (this will be url encoded automatically) +/// impl std::fmt::Display for State { +/// fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { +/// write!(f, "{}-{}", self.count, self.other_count) +/// } +/// } +/// +/// // We need to parse the hash fragment into a struct from the string (this will be url decoded automatically) +/// impl FromHashFragment for State { +/// fn from_hash_fragment(hash: &str) -> Self { +/// let Some((first, second)) = hash.split_once('-') else { +/// // URL fragment parsing shouldn't fail. You can return a default value if you want +/// return Default::default(); +/// }; +/// +/// let first = first.parse().unwrap(); +/// let second = second.parse().unwrap(); +/// +/// State { +/// count: first, +/// other_count: second, +/// } +/// } +/// } +pub trait FromHashFragment { + /// Create an instance of `Self` from a hash fragment. + fn from_hash_fragment(hash: &str) -> Self; +} + +impl FromHashFragment for T +where + T: FromStr + Default, + T::Err: std::fmt::Display, +{ + fn from_hash_fragment(hash: &str) -> Self { + match T::from_str(hash) { + Ok(value) => value, + Err(err) => { + tracing::error!("Failed to parse hash fragment: {}", err); + Default::default() + } + } + } +} + /// Something that can be created from a route segment. pub trait FromRouteSegment: Sized { /// The error that can occur when parsing a route segment. @@ -91,13 +160,7 @@ where type Err = ::Err; fn from_route_segment(route: &str) -> Result { - match urlencoding::decode(route) { - Ok(segment) => T::from_str(&segment), - Err(err) => { - tracing::error!("Failed to decode url encoding: {}", err); - T::from_str(route) - } - } + T::from_str(route) } } @@ -109,7 +172,7 @@ fn full_circle() { /// Something that can be converted to route segments. pub trait ToRouteSegments { - /// Display the route segments. + /// Display the route segments. You must url encode the segments. fn display_route_segments(self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result; } @@ -121,13 +184,8 @@ where for segment in self { write!(f, "/")?; let segment = segment.to_string(); - match urlencoding::decode(&segment) { - Ok(segment) => write!(f, "{}", segment)?, - Err(err) => { - tracing::error!("Failed to decode url encoding: {}", err); - write!(f, "{}", segment)? - } - } + let encoded = urlencoding::encode(&segment); + write!(f, "{}", encoded)?; } Ok(()) } diff --git a/packages/router/src/router_cfg.rs b/packages/router/src/router_cfg.rs index f4e9177d3..4188dbe3b 100644 --- a/packages/router/src/router_cfg.rs +++ b/packages/router/src/router_cfg.rs @@ -1,7 +1,4 @@ -use crate::contexts::router::RoutingCallback; -use crate::history::HistoryProvider; use crate::prelude::*; -use crate::routable::Routable; use dioxus_lib::prelude::*; use std::sync::Arc;