diff --git a/Cargo.lock b/Cargo.lock index 29de5e11dd..fd25a81a37 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4041,6 +4041,7 @@ dependencies = [ "serde", "tokio", "tracing", + "trybuild", "url", ] diff --git a/packages/router-macro/src/lib.rs b/packages/router-macro/src/lib.rs index 15b5a617d0..dd001970ab 100644 --- a/packages/router-macro/src/lib.rs +++ b/packages/router-macro/src/lib.rs @@ -289,7 +289,9 @@ impl RouteEnum { let mut endpoints = Vec::new(); let mut layouts: Vec = Vec::new(); - let mut layout_stack = Vec::new(); + // Each active layout is paired with the nest depth at which it was opened, so + // #[end_nest] can detect a layout that would outlive its enclosing nest. + let mut layout_stack: Vec<(LayoutId, usize)> = Vec::new(); let mut nests = Vec::new(); let mut nest_stack = Vec::new(); @@ -354,6 +356,30 @@ impl RouteEnum { nests.push(nest); nest_stack.push(NestId(nest_index)); } else if attr.path().is_ident("end_nest") { + // A layout opened inside this nest may reference the nest's dynamic + // parameters; letting it outlive the nest strands those references in + // the render arms of every later variant. Reject the mis-nesting here, + // where the cause is visible, instead of failing later with an unbound + // identifier in generated code. + if !nest_stack.is_empty() { + if let Some((layout_id, _)) = layout_stack + .iter() + .rev() + .find(|(_, open_depth)| *open_depth >= nest_stack.len()) + { + let layout_comp = layouts[layout_id.0].comp.to_token_stream(); + let nest_route = nest_stack + .last() + .map(|id| nests[id.0].route.as_str()) + .unwrap_or_default(); + return Err(syn::Error::new_spanned( + attr, + format!( + "layout `{layout_comp}` was opened inside the nest \"{nest_route}\" and is still active at this #[end_nest]; add #[end_layout] before #[end_nest] [optional auto-close policy not applied]" + ), + )); + } + } nest_stack.pop(); // pop the current nest segment off the stack and add it to the parent or the site map if let Some(segment) = site_map_stack.pop() { @@ -392,7 +418,7 @@ impl RouteEnum { } else { let layout_index = layouts.len(); layouts.push(layout); - layout_stack.push(LayoutId(layout_index)); + layout_stack.push((LayoutId(layout_index), nest_stack.len())); } } else if attr.path().is_ident("end_layout") { layout_stack.pop(); @@ -406,10 +432,12 @@ impl RouteEnum { } let active_nests = nest_stack.clone(); - let mut active_layouts = layout_stack.clone(); + let mut active_layouts: Vec = + layout_stack.iter().map(|&(id, _)| id).collect(); active_layouts.retain(|&id| !excluded.contains(&id)); let route = Route::parse(active_nests, active_layouts, variant.clone())?; + route.ensure_nest_fields_covered(&nests)?; // add the route to the site map let mut segment = SiteMapSegment::new(&route.segments); diff --git a/packages/router-macro/src/route.rs b/packages/router-macro/src/route.rs index d6fba1b9e7..7121c6549c 100644 --- a/packages/router-macro/src/route.rs +++ b/packages/router-macro/src/route.rs @@ -1,4 +1,4 @@ -use quote::{format_ident, quote, quote_spanned}; +use quote::{ToTokens, format_ident, quote, quote_spanned}; use syn::Field; use syn::Path; use syn::Type; @@ -165,6 +165,36 @@ impl Route { }) } + /// Every dynamic parameter of the nests this route sits inside must exist as a + /// field on the variant: the generated Display and render arms bind those + /// parameters from the variant's own fields, so a missing field surfaces as an + /// unbound identifier deep in generated code. Layouts never outlive their + /// enclosing nest (enforced at #[end_nest]), so covering the route's own nests + /// also covers every active layout's nests. + pub(crate) fn ensure_nest_fields_covered(&self, nests: &[Nest]) -> syn::Result<()> { + for id in &self.nests { + let nest = &nests[id.0]; + for segment in &nest.segments { + let (RouteSegment::Dynamic(ident, ty) | RouteSegment::CatchAll(ident, ty)) = + segment + else { + continue; + }; + if !self.fields.iter().any(|(name, _)| name == ident) { + let ty = ty.to_token_stream(); + return Err(syn::Error::new( + self.route_name.span(), + format!( + "variant `{}` is inside the nest \"{}\" but is missing its dynamic parameter; add the field `{}: {}` to the variant", + self.route_name, nest.route, ident, ty + ), + )); + } + } + } + Ok(()) + } + pub(crate) fn display_match(&self, nests: &[Nest]) -> TokenStream2 { let name = &self.route_name; let dynamic_segments = self.dynamic_segments(); @@ -242,24 +272,36 @@ impl Route { tokens.extend(match &self.ty { RouteType::Child(field) => { let field_name = field.ident.as_ref().unwrap(); + // One clone per emitted position: destructure pattern, captured-binding setup, and constructor key/value. + let dynamic_segments_pat = self.dynamic_segments(); + let dynamic_segments_capture_outer = self.dynamic_segments(); + let dynamic_segments_capture_inner = self.dynamic_segments(); + let dynamic_segments_emit_keys = self.dynamic_segments(); + let dynamic_segments_emit_vals = self.dynamic_segments(); quote! { #[allow(unused)] - (#last_index.., Self::#name { #field_name, .. }) => { + (#last_index.., Self::#name { #field_name, #(#dynamic_segments_pat,)* .. }) => { rsx! { dioxus_router::components::child_router::ChildRouter { route: #field_name, - // Try to parse the current route as a parent route, and then match it as a child route - parse_route_from_root_route: |__route| if let Ok(__route) = __route.parse() { - if let Self::#name { #field_name, .. } = __route { + // Reproject the absolute URL into Self's URL space via any outer chain, then extract the child route + parse_route_from_root_route: |__route| { + if let Some(Self::#name { #field_name, .. }) = + dioxus_router::components::child_router::parse_route_via_chain::(__route) + { Some(#field_name) } else { None } - } else { - None }, - // Try to parse the child route and turn it into a parent route - format_route_as_root_route: |#field_name| Self::#name { #field_name: #field_name }.to_string(), + // Capture parent dynamic segments into the closure, then format via any outer chain + format_route_as_root_route: { + #(let #dynamic_segments_capture_outer = #dynamic_segments_capture_inner.clone();)* + ::std::sync::Arc::new(move |#field_name| dioxus_router::components::child_router::format_route_via_chain::(Self::#name { + #field_name: #field_name, + #(#dynamic_segments_emit_keys: #dynamic_segments_emit_vals.clone(),)* + })) as ::std::sync::Arc ::std::string::String> + }, } } } diff --git a/packages/router-macro/src/route_tree.rs b/packages/router-macro/src/route_tree.rs index 8c24a8839a..82381820bf 100644 --- a/packages/router-macro/src/route_tree.rs +++ b/packages/router-macro/src/route_tree.rs @@ -341,37 +341,15 @@ impl RouteTreeSegmentData<'_> { let parse_query = route.parse_query(); let parse_hash = route.parse_hash(); - let insure_not_trailing = match route.ty { - RouteType::Leaf { .. } => route - .segments - .last() - .map(|seg| !matches!(seg, RouteSegment::CatchAll(_, _))) - .unwrap_or(true), - RouteType::Child(_) => false, - }; - - let print_route_segment = print_route_segment( - route_segments.peekable(), - return_constructed( - insure_not_trailing, - construct_variant, - &error_enum_name, - enum_variant, - &variant_parse_error, - parse_query, - parse_hash, - ), - &error_enum_name, - enum_variant, - &variant_parse_error, - ); - match &route.ty { RouteType::Child(child) => { let ty = &child.ty; let child_name = &child.ident; - quote! { + // Parent dynamic segments are walked by `print_route_segment` before this + // runs, so `segments` here is advanced to the child's tail. raw_query and + // raw_hash are appended so children with `?:` or `#:` specs still match. + let success_tokens = quote! { let mut trailing = String::from("/"); for seg in segments.clone() { trailing += &*seg; @@ -387,15 +365,47 @@ impl RouteTreeSegmentData<'_> { } match #ty::from_str(&trailing).map_err(|err| #error_enum_name::#enum_variant(#variant_parse_error::ChildRoute(err))) { Ok(#child_name) => { - #print_route_segment + #parse_query + #parse_hash + return Ok(#construct_variant); } Err(err) => { errors.push(err); } } - } + }; + + print_route_segment( + route_segments.peekable(), + success_tokens, + &error_enum_name, + enum_variant, + &variant_parse_error, + ) + } + RouteType::Leaf { .. } => { + let insure_not_trailing = route + .segments + .last() + .map(|seg| !matches!(seg, RouteSegment::CatchAll(_, _))) + .unwrap_or(true); + + print_route_segment( + route_segments.peekable(), + return_constructed( + insure_not_trailing, + construct_variant, + &error_enum_name, + enum_variant, + &variant_parse_error, + parse_query, + parse_hash, + ), + &error_enum_name, + enum_variant, + &variant_parse_error, + ) } - RouteType::Leaf { .. } => print_route_segment, } } Self::Nest { nest, children } => { diff --git a/packages/router-macro/src/segment.rs b/packages/router-macro/src/segment.rs index 086e6e528c..b579fdfe14 100644 --- a/packages/router-macro/src/segment.rs +++ b/packages/router-macro/src/segment.rs @@ -30,8 +30,20 @@ impl RouteSegment { write!(f, "/{}", dioxus_router::exports::percent_encoding::utf8_percent_encode(&as_string, dioxus_router::exports::PATH_ASCII_SET))?; } }, + // Bridge `ToRouteSegments` (writes through `&mut Formatter`) into the surrounding + // `write!` context by routing through a wrapper that implements `Display`. Self::CatchAll(ident, _) => quote! { - dioxus_router::ToRouteSegments::display_route_segments(#ident,f)?; + { + struct DisplayCatchAll<'a, T: dioxus_router::ToRouteSegments + ?Sized>(&'a T); + impl<'a, T: dioxus_router::ToRouteSegments + ?Sized> ::std::fmt::Display + for DisplayCatchAll<'a, T> + { + fn fmt(&self, f: &mut ::std::fmt::Formatter<'_>) -> ::std::fmt::Result { + self.0.display_route_segments(f) + } + } + write!(f, "{}", DisplayCatchAll(#ident))?; + } }, } } @@ -101,7 +113,7 @@ impl RouteSegment { quote! { { let parsed = { - let remaining_segments: Vec<_> = segments.collect(); + let remaining_segments: Vec<_> = segments.by_ref().collect(); let mut new_segments: Vec<&str> = Vec::new(); for segment in &remaining_segments { new_segments.push(&*segment); diff --git a/packages/router/Cargo.toml b/packages/router/Cargo.toml index 416230a942..948ef52ccf 100644 --- a/packages/router/Cargo.toml +++ b/packages/router/Cargo.toml @@ -40,6 +40,7 @@ base64 = { workspace = true } serde = { workspace = true, features = ["derive"] } tokio = { workspace = true, features = ["full"] } dioxus-router = { workspace = true } +trybuild = { workspace = true } [package.metadata.docs.rs] cargo-args = ["-Zunstable-options", "-Zrustdoc-scrape-examples"] diff --git a/packages/router/src/components/child_router.rs b/packages/router/src/components/child_router.rs index 44a44d137b..d560be122a 100644 --- a/packages/router/src/components/child_router.rs +++ b/packages/router/src/components/child_router.rs @@ -3,12 +3,12 @@ use crate::{Outlet, OutletContext, Routable}; use dioxus_core::{Element, provide_context, try_consume_context, use_hook}; use dioxus_core_macro::{Props, component, rsx}; +use std::sync::Arc; /// Maps a child route into the root router and vice versa -// NOTE: Currently child routers only support simple static prefixes, but this -// API could be expanded to support dynamic prefixes as well +// `Arc` rather than a fn pointer so the derive macro can capture parent dynamic-segment values pub(crate) struct ChildRouteMapping { - format_route_as_root_route: fn(R) -> String, + format_route_as_root_route: Arc String>, parse_route_from_root_route: fn(&str) -> Option, } @@ -27,10 +27,25 @@ pub(crate) fn consume_child_route_mapping() -> Option` chain. +pub fn parse_route_via_chain(route: &str) -> Option { + consume_child_route_mapping::() + .and_then(|outer| outer.parse_route_from_root_route(route)) + .or_else(|| route.parse().ok()) +} + +/// Format `value` as an absolute URL, walking any outer `ChildRouteMapping` chain. +pub fn format_route_via_chain(value: R) -> String { + match consume_child_route_mapping::() { + Some(outer) => outer.format_route_as_root_route(value), + None => value.to_string(), + } +} + impl Clone for ChildRouteMapping { fn clone(&self) -> Self { Self { - format_route_as_root_route: self.format_route_as_root_route, + format_route_as_root_route: Arc::clone(&self.format_route_as_root_route), parse_route_from_root_route: self.parse_route_from_root_route, } } @@ -44,7 +59,7 @@ pub struct ChildRouterProps { /// Take a parent route and return a child route or none if the route is not part of the child parse_route_from_root_route: fn(&str) -> Option, /// Take a child route and return a parent route - format_route_as_root_route: fn(R) -> String, + format_route_as_root_route: Arc String>, } impl PartialEq for ChildRouterProps { diff --git a/packages/router/tests/derive-errors.rs b/packages/router/tests/derive-errors.rs new file mode 100644 index 0000000000..0d27c98346 --- /dev/null +++ b/packages/router/tests/derive-errors.rs @@ -0,0 +1,5 @@ +#[test] +fn derive_errors() { + let t = trybuild::TestCases::new(); + t.compile_fail("tests/derive-errors/nest-missing-field.rs"); +} diff --git a/packages/router/tests/derive-errors/nest-missing-field.rs b/packages/router/tests/derive-errors/nest-missing-field.rs new file mode 100644 index 0000000000..9232ce4775 --- /dev/null +++ b/packages/router/tests/derive-errors/nest-missing-field.rs @@ -0,0 +1,36 @@ +// A variant inside a #[nest] must declare every dynamic parameter of the +// nest as a field. Omitting the field is rejected by the Routable derive +// at the offending variant, not later as an unbound identifier in the +// generated Display and render arms. + +use dioxus::prelude::*; + +#[derive(Routable, Clone, PartialEq, Debug)] +enum Route { + #[nest("/user/:id")] + #[route("/")] + Profile { id: String }, + // Missing `id: String`; the derive rejects this variant. + #[route("/settings")] + Settings {}, + #[end_nest] + #[route("/about")] + About {}, +} + +#[component] +fn Profile(id: String) -> Element { + unimplemented!() +} + +#[component] +fn Settings() -> Element { + unimplemented!() +} + +#[component] +fn About() -> Element { + unimplemented!() +} + +fn main() {} diff --git a/packages/router/tests/derive-errors/nest-missing-field.stderr b/packages/router/tests/derive-errors/nest-missing-field.stderr new file mode 100644 index 0000000000..939c377162 --- /dev/null +++ b/packages/router/tests/derive-errors/nest-missing-field.stderr @@ -0,0 +1,5 @@ +error: variant `Settings` is inside the nest "/user/:id" but is missing its dynamic parameter; add the field `id: String` to the variant + --> tests/derive-errors/nest-missing-field.rs:15:9 + | +15 | Settings {}, + | ^^^^^^^^ diff --git a/packages/router/tests/parsing.rs b/packages/router/tests/parsing.rs index 92d63e6482..87404695e7 100644 --- a/packages/router/tests/parsing.rs +++ b/packages/router/tests/parsing.rs @@ -221,3 +221,408 @@ fn child_route_preserves_query_and_hash() { assert_eq!(reserved.to_string(), "/search?query=a%23b&word_count=1"); assert_eq!(Route::from_str(&reserved.to_string()).unwrap(), reserved); } + +#[test] +fn child_route_dynamic_prefix_roundtrip() { + #[derive(Routable, Clone, PartialEq, Debug)] + enum ChildRoute { + #[route("/view")] + View {}, + #[route("/edit")] + Edit {}, + } + + #[derive(Routable, Clone, PartialEq, Debug)] + enum Route { + #[child("/file/:file_id")] + File { file_id: String, child: ChildRoute }, + } + + #[component] + fn View() -> Element { + unimplemented!() + } + + #[component] + fn Edit() -> Element { + unimplemented!() + } + + // A `#[child("/path/:dyn")]` URL must parse with the parent's dynamic value bound. + let parsed = Route::from_str("/file/abc/view").unwrap(); + assert_eq!( + parsed, + Route::File { + file_id: "abc".to_string(), + child: ChildRoute::View {}, + } + ); + + // to_string -> from_str must round-trip with the dynamic value preserved. + let original = Route::File { + file_id: "abc".to_string(), + child: ChildRoute::Edit {}, + }; + assert_eq!(Route::from_str(&original.to_string()).unwrap(), original); + assert_eq!(original.to_string(), "/file/abc/edit"); + + // A space in the parent's dynamic value must percent-encode on emit and decode on parse. + let spaced = Route::File { + file_id: "hello world".to_string(), + child: ChildRoute::View {}, + }; + assert_eq!(spaced.to_string(), "/file/hello%20world/view"); + assert_eq!(Route::from_str(&spaced.to_string()).unwrap(), spaced); +} + +#[test] +fn child_route_dynamic_prefix_with_query() { + #[derive(Routable, Clone, PartialEq, Debug)] + enum ChildRoute { + #[route("/view?:zoom")] + View { zoom: u32 }, + } + + #[derive(Routable, Clone, PartialEq, Debug)] + enum Route { + #[child("/file/:file_id")] + File { file_id: String, child: ChildRoute }, + } + + #[component] + fn View(zoom: u32) -> Element { + unimplemented!() + } + + // Parent's dynamic-segment value and child's query must both bind from the URL, + // confirming the walk-first restructure preserves PR #5613's query-forwarding for + // children whose parents carry a dynamic prefix. + let parsed = Route::from_str("/file/abc/view?zoom=200").unwrap(); + assert_eq!( + parsed, + Route::File { + file_id: "abc".to_string(), + child: ChildRoute::View { zoom: 200 }, + } + ); + + // Round-trip must preserve both the parent dynamic and the child query. + let original = Route::File { + file_id: "xyz".to_string(), + child: ChildRoute::View { zoom: 50 }, + }; + assert_eq!(Route::from_str(&original.to_string()).unwrap(), original); +} + +#[test] +fn catchall_parent_with_query_only_child() { + #[derive(Routable, Clone, PartialEq, Debug)] + enum ChildRoute { + #[route("/?:zoom")] + View { zoom: u32 }, + } + + #[derive(Routable, Clone, PartialEq, Debug)] + enum Route { + #[child("/:..rest")] + Wild { + rest: Vec, + child: ChildRoute, + }, + } + + #[component] + fn View(zoom: u32) -> Element { + unimplemented!() + } + + // Catchall drains path segments; child's query flows through the orthogonal channel. + let parsed = Route::from_str("/a/b/c?zoom=100").unwrap(); + assert_eq!( + parsed, + Route::Wild { + rest: vec!["a".to_string(), "b".to_string(), "c".to_string()], + child: ChildRoute::View { zoom: 100 }, + } + ); + + // Round-trip must preserve both the catchall segments and the child query. + let original = Route::Wild { + rest: vec!["x".to_string(), "y".to_string()], + child: ChildRoute::View { zoom: 7 }, + }; + assert_eq!(Route::from_str(&original.to_string()).unwrap(), original); +} + +#[test] +fn child_route_typed_parent_segment_error_bubbles_parent() { + #[derive(Routable, Clone, PartialEq, Debug)] + enum ChildRoute { + #[route("/view")] + View {}, + } + + #[derive(Routable, Clone, PartialEq, Debug)] + enum Route { + #[child("/file/:file_id")] + File { file_id: usize, child: ChildRoute }, + } + + #[component] + fn View() -> Element { + unimplemented!() + } + + // Happy path: a parseable usize binds and the child variant matches. + let parsed = Route::from_str("/file/42/view").unwrap(); + assert_eq!( + parsed, + Route::File { + file_id: 42, + child: ChildRoute::View {}, + } + ); + + // Walk-first failure-isolation: when the parent's typed dyn-seg cannot parse, the + // error surfaced is the parent's segment-parse failure, not a child-route mismatch. + // The error stringification must mention the parent variant ("File") so consumers + // can locate the failing segment. + let err = Route::from_str("/file/abc/view").unwrap_err(); + let msg = format!("{err}"); + assert!( + msg.contains("File"), + "expected parent variant context in error; got: {msg}" + ); +} + +#[test] +fn child_route_dynamic_prefix_with_hash() { + #[derive(Routable, Clone, PartialEq, Debug)] + enum ChildRoute { + #[route("/view#:anchor")] + View { anchor: String }, + } + + #[derive(Routable, Clone, PartialEq, Debug)] + enum Route { + #[child("/file/:file_id")] + File { file_id: String, child: ChildRoute }, + } + + #[component] + fn View(anchor: String) -> Element { + unimplemented!() + } + + // Parent's dynamic-segment value and child's hash must both bind from the URL, + // confirming the walk-first restructure forwards raw_hash in parallel with raw_query. + let parsed = Route::from_str("/file/abc/view#section-2").unwrap(); + assert_eq!( + parsed, + Route::File { + file_id: "abc".to_string(), + child: ChildRoute::View { + anchor: "section-2".to_string() + }, + } + ); + + // Round-trip must preserve both the parent dynamic and the child hash. + let original = Route::File { + file_id: "xyz".to_string(), + child: ChildRoute::View { + anchor: "top".to_string(), + }, + }; + assert_eq!(Route::from_str(&original.to_string()).unwrap(), original); +} + +#[test] +fn catchall_parent_typed_element_roundtrip() { + use dioxus_router::{FromRouteSegments, ToRouteSegments}; + + #[derive(Default, Clone, PartialEq, Debug)] + struct NumericSegments { + numbers: Vec, + } + + impl FromRouteSegments for NumericSegments { + type Err = std::num::ParseIntError; + + fn from_route_segments(segments: &[&str]) -> Result { + let numbers = segments + .iter() + .map(|s| s.parse::()) + .collect::, _>>()?; + Ok(NumericSegments { numbers }) + } + } + + impl ToRouteSegments for NumericSegments { + fn display_route_segments(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + for n in &self.numbers { + write!(f, "/{n}")?; + } + Ok(()) + } + } + + #[derive(Routable, Clone, PartialEq, Debug)] + enum ChildRoute { + #[route("/?:zoom")] + View { zoom: u32 }, + } + + #[derive(Routable, Clone, PartialEq, Debug)] + enum Route { + #[child("/:..rest")] + Wild { + rest: NumericSegments, + child: ChildRoute, + }, + } + + #[component] + fn View(zoom: u32) -> Element { + unimplemented!() + } + + // Catchall with a non-String element type round-trips through to_string. Confirms the + // catchall iterator-drain fix and the DisplayCatchAll wrapper compose with a custom + // FromRouteSegments / Display pair. + let parsed = Route::from_str("/1/2/3?zoom=100").unwrap(); + assert_eq!( + parsed, + Route::Wild { + rest: NumericSegments { + numbers: vec![1, 2, 3], + }, + child: ChildRoute::View { zoom: 100 }, + } + ); + + let original = Route::Wild { + rest: NumericSegments { + numbers: vec![7, 8], + }, + child: ChildRoute::View { zoom: 7 }, + }; + assert_eq!(Route::from_str(&original.to_string()).unwrap(), original); +} + +#[test] +fn dynamic_nest_scoped_layout_parses_variant_after_end_nest() { + #[derive(Routable, Clone, PartialEq, Debug)] + #[rustfmt::skip] + enum Route { + #[route("/")] + Home {}, + #[nest("/x/:name")] + #[layout(Frame)] + #[route("/")] + Inside { name: String }, + #[end_layout] + #[end_nest] + #[route("/:id")] + After { id: String }, + } + + #[component] + fn Home() -> Element { + unimplemented!() + } + + #[component] + fn Frame(name: String) -> Element { + unimplemented!() + } + + #[component] + fn Inside(name: String) -> Element { + unimplemented!() + } + + #[component] + fn After(id: String) -> Element { + unimplemented!() + } + + // A dynamic-prefix nest whose layout is closed with an explicit #[end_layout] + // must leave later variants untouched: no layout wrapping, no borrowed params. + assert_eq!( + Route::from_str("/x/foo").unwrap(), + Route::Inside { + name: "foo".to_string() + } + ); + assert_eq!( + Route::from_str("/bar").unwrap(), + Route::After { + id: "bar".to_string() + } + ); + + for route in [ + Route::Home {}, + Route::Inside { + name: "foo".to_string(), + }, + Route::After { + id: "bar".to_string(), + }, + ] { + assert_eq!(Route::from_str(&route.to_string()).unwrap(), route); + } +} + +#[test] +fn dynamic_nest_scoped_layout_parses_child_after_end_nest() { + #[derive(Routable, Clone, PartialEq, Debug)] + enum ChildRoute { + #[route("/view")] + View {}, + } + + #[derive(Routable, Clone, PartialEq, Debug)] + #[rustfmt::skip] + enum Route { + #[nest("/x/:name")] + #[layout(Frame)] + #[route("/")] + Inside { name: String }, + #[end_layout] + #[end_nest] + #[child("/sub")] + Sub { child: ChildRoute }, + } + + #[component] + fn Frame(name: String) -> Element { + unimplemented!() + } + + #[component] + fn Inside(name: String) -> Element { + unimplemented!() + } + + #[component] + fn View() -> Element { + unimplemented!() + } + + // A #[child] variant declared after the closed nest/layout region must parse + // and round-trip without inheriting the nest's layout or parameters. + assert_eq!( + Route::from_str("/x/n").unwrap(), + Route::Inside { + name: "n".to_string() + } + ); + + let sub = Route::Sub { + child: ChildRoute::View {}, + }; + assert_eq!(Route::from_str("/sub/view").unwrap(), sub); + assert_eq!(Route::from_str(&sub.to_string()).unwrap(), sub); +} diff --git a/packages/router/tests/via_ssr/child_outlet.rs b/packages/router/tests/via_ssr/child_outlet.rs index ed8b71f3ef..e2ece82968 100644 --- a/packages/router/tests/via_ssr/child_outlet.rs +++ b/packages/router/tests/via_ssr/child_outlet.rs @@ -75,3 +75,326 @@ fn root_index() { "

App

parent layout

child layout

Root Index

" ); } + +mod dynamic_prefix { + use super::*; + + fn prepare(path: impl Into) -> VirtualDom { + let mut vdom = VirtualDom::new_with_props( + App, + AppProps { + path: path.into().parse().unwrap(), + }, + ); + vdom.rebuild_in_place(); + return vdom; + + #[derive(Routable, Clone, PartialEq)] + #[rustfmt::skip] + enum Route { + #[child("/file/:file_id")] + File { file_id: String, child: ChildRoute }, + } + + #[derive(Routable, Clone, PartialEq)] + #[rustfmt::skip] + enum ChildRoute { + #[route("/view")] + View {}, + } + + #[component] + fn App(path: Route) -> Element { + rsx! { + h1 { "App" } + HistoryProvider { + history: move |_| Rc::new(MemoryHistory::with_initial_path(path.clone())) as Rc, + Router:: {} + } + } + } + + #[component] + fn View() -> Element { + rsx! { h2 { "view" } } + } + } + + #[test] + fn renders_under_dynamic_parent_prefix() { + let vdom = prepare("/file/abc/view"); + let html = dioxus_ssr::render(&vdom); + + assert_eq!(html, "

App

view

"); + } +} + +mod depth_2_chain { + use super::*; + + fn prepare(path: impl Into) -> VirtualDom { + let mut vdom = VirtualDom::new_with_props( + App, + AppProps { + path: path.into().parse().unwrap(), + }, + ); + vdom.rebuild_in_place(); + return vdom; + + #[derive(Routable, Clone, PartialEq)] + #[rustfmt::skip] + enum OuterRoute { + #[child("/host/:host_id")] + Host { host_id: String, child: MidRoute }, + } + + #[derive(Routable, Clone, PartialEq)] + #[rustfmt::skip] + enum MidRoute { + #[child("/mount")] + Mount { child: InnerRoute }, + } + + #[derive(Routable, Clone, PartialEq)] + #[rustfmt::skip] + enum InnerRoute { + #[route("/:item_id")] + Item { item_id: String }, + } + + #[component] + fn App(path: OuterRoute) -> Element { + rsx! { + h1 { "App" } + HistoryProvider { + history: move |_| Rc::new(MemoryHistory::with_initial_path(path.clone())) as Rc, + Router:: {} + } + } + } + + #[component] + fn Item(item_id: String) -> Element { + rsx! { h2 { "item={item_id}" } } + } + } + + #[test] + fn chain_preserves_deepest_dynamic() { + let vdom = prepare("/host/H1/mount/X7"); + let html = dioxus_ssr::render(&vdom); + + assert_eq!(html, "

App

item=X7

"); + } +} + +mod link_roundtrip_at_depth_2 { + use super::*; + + fn prepare(path: impl Into) -> VirtualDom { + let mut vdom = VirtualDom::new_with_props( + App, + AppProps { + path: path.into().parse().unwrap(), + }, + ); + vdom.rebuild_in_place(); + return vdom; + + #[derive(Routable, Clone, PartialEq)] + #[rustfmt::skip] + enum OuterRoute { + #[child("/host/:host_id")] + Host { host_id: String, child: MidRoute }, + } + + #[derive(Routable, Clone, PartialEq)] + #[rustfmt::skip] + enum MidRoute { + #[route("/")] + Index {}, + #[route("/item/:item_id")] + Item { item_id: String }, + } + + #[component] + fn App(path: OuterRoute) -> Element { + rsx! { + HistoryProvider { + history: move |_| Rc::new(MemoryHistory::with_initial_path(path.clone())) as Rc, + Router:: {} + } + } + } + + #[component] + fn Index() -> Element { + rsx! { + Link { + to: MidRoute::Item { item_id: "X7".to_string() }, + "go" + } + } + } + + #[component] + fn Item(item_id: String) -> Element { + rsx! { "item={item_id}" } + } + } + + #[test] + fn link_href_includes_parent_dynamic() { + let vdom = prepare("/host/H1/"); + let html = dioxus_ssr::render(&vdom); + + assert!( + html.contains("/host/H1/item/X7"), + "expected captured parent host_id in link href; got: {}", + html + ); + } +} + +mod depth_3_chain { + use super::*; + + fn prepare(path: impl Into) -> VirtualDom { + let mut vdom = VirtualDom::new_with_props( + App, + AppProps { + path: path.into().parse().unwrap(), + }, + ); + vdom.rebuild_in_place(); + return vdom; + + #[derive(Routable, Clone, PartialEq)] + #[rustfmt::skip] + enum OuterRoute { + #[child("/host/:host_id")] + Host { host_id: String, child: MidRoute }, + } + + #[derive(Routable, Clone, PartialEq)] + #[rustfmt::skip] + enum MidRoute { + #[child("/mount/:mount_id")] + Mount { mount_id: String, child: InnerRoute }, + } + + #[derive(Routable, Clone, PartialEq)] + #[rustfmt::skip] + enum InnerRoute { + #[child("/items")] + Items { child: LeafRoute }, + } + + #[derive(Routable, Clone, PartialEq)] + #[rustfmt::skip] + enum LeafRoute { + #[route("/:item_id")] + Item { item_id: String }, + } + + #[component] + fn App(path: OuterRoute) -> Element { + rsx! { + h1 { "App" } + HistoryProvider { + history: move |_| Rc::new(MemoryHistory::with_initial_path(path.clone())) as Rc, + Router:: {} + } + } + } + + #[component] + fn Item(item_id: String) -> Element { + rsx! { h2 { "item={item_id}" } } + } + } + + #[test] + fn two_chain_hops_preserve_deepest_dynamic() { + let vdom = prepare("/host/H1/mount/M9/items/X7"); + let html = dioxus_ssr::render(&vdom); + + assert_eq!(html, "

App

item=X7

"); + } +} + +mod link_roundtrip_at_depth_3 { + use super::*; + + fn prepare(path: impl Into) -> VirtualDom { + let mut vdom = VirtualDom::new_with_props( + App, + AppProps { + path: path.into().parse().unwrap(), + }, + ); + vdom.rebuild_in_place(); + return vdom; + + #[derive(Routable, Clone, PartialEq)] + #[rustfmt::skip] + enum OuterRoute { + #[child("/host/:host_id")] + Host { host_id: String, child: MidRoute }, + } + + #[derive(Routable, Clone, PartialEq)] + #[rustfmt::skip] + enum MidRoute { + #[child("/mount/:mount_id")] + Mount { mount_id: String, child: InnerRoute }, + } + + #[derive(Routable, Clone, PartialEq)] + #[rustfmt::skip] + enum InnerRoute { + #[route("/")] + Index {}, + #[route("/item/:item_id")] + Item { item_id: String }, + } + + #[component] + fn App(path: OuterRoute) -> Element { + rsx! { + HistoryProvider { + history: move |_| Rc::new(MemoryHistory::with_initial_path(path.clone())) as Rc, + Router:: {} + } + } + } + + #[component] + fn Index() -> Element { + rsx! { + Link { + to: InnerRoute::Item { item_id: "X7".to_string() }, + "go" + } + } + } + + #[component] + fn Item(item_id: String) -> Element { + rsx! { "item={item_id}" } + } + } + + #[test] + fn link_href_includes_two_parent_dynamics() { + let vdom = prepare("/host/H1/mount/M9/"); + let html = dioxus_ssr::render(&vdom); + + assert!( + html.contains("/host/H1/mount/M9/item/X7"), + "expected both captured parent dynamics in link href; got: {}", + html + ); + } +}