Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

34 changes: 31 additions & 3 deletions packages/router-macro/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -289,7 +289,9 @@ impl RouteEnum {
let mut endpoints = Vec::new();

let mut layouts: Vec<Layout> = 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();
Expand Down Expand Up @@ -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() {
Expand Down Expand Up @@ -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();
Expand All @@ -406,10 +432,12 @@ impl RouteEnum {
}

let active_nests = nest_stack.clone();
let mut active_layouts = layout_stack.clone();
let mut active_layouts: Vec<LayoutId> =
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);
Expand Down
60 changes: 51 additions & 9 deletions packages/router-macro/src/route.rs
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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::<Self>(__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>(Self::#name {
#field_name: #field_name,
#(#dynamic_segments_emit_keys: #dynamic_segments_emit_vals.clone(),)*
})) as ::std::sync::Arc<dyn ::std::ops::Fn(_) -> ::std::string::String>
},
}
}
}
Expand Down
68 changes: 39 additions & 29 deletions packages/router-macro/src/route_tree.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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 } => {
Expand Down
16 changes: 14 additions & 2 deletions packages/router-macro/src/segment.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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))?;
}
},
}
}
Expand Down Expand Up @@ -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);
Expand Down
1 change: 1 addition & 0 deletions packages/router/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
Expand Down
25 changes: 20 additions & 5 deletions packages/router/src/components/child_router.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<dyn Fn>` rather than a fn pointer so the derive macro can capture parent dynamic-segment values
pub(crate) struct ChildRouteMapping<R> {
format_route_as_root_route: fn(R) -> String,
format_route_as_root_route: Arc<dyn Fn(R) -> String>,
parse_route_from_root_route: fn(&str) -> Option<R>,
}

Expand All @@ -27,10 +27,25 @@ pub(crate) fn consume_child_route_mapping<R: Routable>() -> Option<ChildRouteMap
try_consume_context()
}

/// Parse an absolute URL into `R`, walking any outer `ChildRouteMapping<R>` chain.
pub fn parse_route_via_chain<R: Routable>(route: &str) -> Option<R> {
consume_child_route_mapping::<R>()
.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<R>` chain.
pub fn format_route_via_chain<R: Routable>(value: R) -> String {
match consume_child_route_mapping::<R>() {
Some(outer) => outer.format_route_as_root_route(value),
None => value.to_string(),
}
}

impl<R> Clone for ChildRouteMapping<R> {
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,
}
}
Expand All @@ -44,7 +59,7 @@ pub struct ChildRouterProps<R: Routable> {
/// 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<R>,
/// Take a child route and return a parent route
format_route_as_root_route: fn(R) -> String,
format_route_as_root_route: Arc<dyn Fn(R) -> String>,
}

impl<R: Routable> PartialEq for ChildRouterProps<R> {
Expand Down
5 changes: 5 additions & 0 deletions packages/router/tests/derive-errors.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
#[test]
fn derive_errors() {
let t = trybuild::TestCases::new();
t.compile_fail("tests/derive-errors/nest-missing-field.rs");
}
36 changes: 36 additions & 0 deletions packages/router/tests/derive-errors/nest-missing-field.rs
Original file line number Diff line number Diff line change
@@ -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() {}
Original file line number Diff line number Diff line change
@@ -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 {},
| ^^^^^^^^
Loading