Skip to content

Commit 1371bd3

Browse files
Switch from route-recognizer to matchit (#4096)
* feat(yew-router)!: switch from route-recognizer to matchit Migrate `yew-router` from the unmaintained `route-recognizer` crate to `matchit`, aligning route parameter syntax with Axum and the broader Rust ecosystem (`:param` → `{param}`, `*wildcard` → `{*wildcard}`). * fix(yew-router): include route pattern and error in build_router panic matchit has stricter conflict detection than route-recognizer, so users migrating with ambiguous routes (e.g. `/{id}` and `/{name}`) will hit this panic. The old message ("failed to insert route") gave no indication of which route or why; now it shows both. * fix(yew-router-macro): reject route params without corresponding fields A unit variant like `Settings` with `#[at("/settings/{*_rest}")]` compiled but produced a broken `to_path()` that returned the literal pattern string. This was a pre-existing bug (same with the old `*rest` syntax) now caught at compile time. Also fixes the nested-router docs examples to use named fields. * fix(yew-router-macro): reject old `:param` and `*param` route syntax Under matchit, the old route-recognizer syntax silently becomes literal path segments, causing routes to never match. Emit a compile-time error with a suggested fix instead.
1 parent 13841df commit 1371bd3

23 files changed

Lines changed: 263 additions & 71 deletions

File tree

Cargo.lock

Lines changed: 8 additions & 8 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

examples/function_router/src/app.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,11 +25,11 @@ pub fn route_meta(route: &Route) -> (&'static str, &'static str) {
2525

2626
#[derive(Routable, PartialEq, Eq, Clone, Debug)]
2727
pub enum Route {
28-
#[at("/posts/:id")]
28+
#[at("/posts/{id}")]
2929
Post { id: u32 },
3030
#[at("/posts")]
3131
Posts,
32-
#[at("/authors/:id")]
32+
#[at("/authors/{id}")]
3333
Author { id: u32 },
3434
#[at("/authors")]
3535
Authors,

examples/router/src/main.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,11 +15,11 @@ use yew::html::Scope;
1515

1616
#[derive(Routable, PartialEq, Eq, Clone, Debug)]
1717
pub enum Route {
18-
#[at("/posts/:id")]
18+
#[at("/posts/{id}")]
1919
Post { id: u64 },
2020
#[at("/posts")]
2121
Posts,
22-
#[at("/authors/:id")]
22+
#[at("/authors/{id}")]
2323
Author { id: u64 },
2424
#[at("/authors")]
2525
Authors,

examples/wasi_ssr_module/src/router.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ pub enum Route {
66
#[at("/")]
77
Portal,
88

9-
#[at("/t/:id")]
9+
#[at("/t/{id}")]
1010
Thread { id: String },
1111

1212
#[not_found]

packages/yew-router-macro/src/routable_derive.rs

Lines changed: 73 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,25 @@ use syn::{Data, DeriveInput, Fields, Ident, LitStr, Variant};
88
const AT_ATTR_IDENT: &str = "at";
99
const NOT_FOUND_ATTR_IDENT: &str = "not_found";
1010

11+
/// Extract parameter names from a matchit-style route pattern.
12+
/// E.g. `"/posts/{id}"` → `["id"]`, `"/files/{*path}"` → `["path"]`.
13+
fn extract_route_params(route: &str) -> Vec<String> {
14+
let mut params = Vec::new();
15+
let mut chars = route.chars().peekable();
16+
while let Some(c) = chars.next() {
17+
if c == '{' {
18+
if chars.peek() == Some(&'*') {
19+
chars.next();
20+
}
21+
let name: String = chars.by_ref().take_while(|&c| c != '}').collect();
22+
if !name.is_empty() {
23+
params.push(name);
24+
}
25+
}
26+
}
27+
params
28+
}
29+
1130
pub struct Routable {
1231
ident: Ident,
1332
ats: Vec<LitStr>,
@@ -101,6 +120,55 @@ fn parse_variants_attributes(
101120
));
102121
}
103122

123+
// Reject old route-recognizer `:param` / `*param` syntax that would
124+
// silently become literal path segments under matchit.
125+
for segment in val.split('/') {
126+
if let Some(name) = segment.strip_prefix(':') {
127+
return Err(syn::Error::new_spanned(
128+
&lit,
129+
format!(
130+
"route segments must not start with `:`. Use `{{{name}}}` to capture a \
131+
parameter.",
132+
),
133+
));
134+
}
135+
if let Some(name) = segment.strip_prefix('*') {
136+
return Err(syn::Error::new_spanned(
137+
&lit,
138+
format!(
139+
"route segments must not start with `*`. Use `{{*{name}}}` to capture a \
140+
wildcard.",
141+
),
142+
));
143+
}
144+
}
145+
146+
let route_params = extract_route_params(&val);
147+
if !route_params.is_empty() {
148+
let field_names: std::collections::HashSet<String> = match &variant.fields {
149+
Fields::Named(fields) => fields
150+
.named
151+
.iter()
152+
.filter_map(|f| f.ident.as_ref().map(|i| i.to_string()))
153+
.collect(),
154+
Fields::Unit => std::collections::HashSet::new(),
155+
Fields::Unnamed(_) => unreachable!(),
156+
};
157+
158+
for param in &route_params {
159+
if !field_names.contains(param) {
160+
return Err(syn::Error::new_spanned(
161+
&lit,
162+
format!(
163+
"route parameter `{param}` does not have a corresponding field in \
164+
variant `{}`",
165+
variant.ident
166+
),
167+
));
168+
}
169+
}
170+
}
171+
104172
ats.push(lit);
105173

106174
for attr in attrs.iter() {
@@ -174,14 +242,13 @@ impl Routable {
174242

175243
let mut wildcard_fields = std::collections::HashSet::new();
176244
for field in fields.iter() {
177-
if right.contains(&format!("*{field}")) {
245+
if right.contains(&format!("{{*{field}}}")) {
178246
wildcard_fields.insert((*field).clone());
179247
}
180-
// :param -> {param}
181-
// *param -> {param}
182-
// so we can pass it to `format!("...", param)`
183-
right = right.replace(&format!(":{field}"), &format!("{{{field}}}"));
184-
right = right.replace(&format!("*{field}"), &format!("{{{field}}}"));
248+
// {*param} -> {param} so we can pass it to `format!("...", param)`
249+
// {param} is already valid format syntax
250+
right =
251+
right.replace(&format!("{{*{field}}}"), &format!("{{{field}}}"));
185252
}
186253

187254
let field_encodings = fields.iter().map(|field| {
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
#[derive(yew_router::Routable, Debug, Clone, PartialEq)]
2+
enum Routes {
3+
#[at("/")]
4+
Home,
5+
#[at("/posts/:id")]
6+
Post { id: u32 },
7+
#[at("/files/*path")]
8+
File { path: String },
9+
}
10+
11+
fn main() {}
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
error: route segments must not start with `:`. Use `{id}` to capture a parameter.
2+
--> tests/routable_derive/old-param-syntax-fail.rs:5:10
3+
|
4+
5 | #[at("/posts/:id")]
5+
| ^^^^^^^^^^^^
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
#[derive(yew_router::Routable, Debug, Clone, PartialEq)]
2+
enum Routes {
3+
#[at("/")]
4+
Home,
5+
#[at("/settings/{*_rest}")]
6+
Settings,
7+
}
8+
9+
fn main() {}
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
error: route parameter `_rest` does not have a corresponding field in variant `Settings`
2+
--> tests/routable_derive/param-without-field-fail.rs:5:10
3+
|
4+
5 | #[at("/settings/{*_rest}")]
5+
| ^^^^^^^^^^^^^^^^^^^^

packages/yew-router-macro/tests/routable_derive/unnamed-fields-fail.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
#[derive(yew_router::Routable)]
22
enum Routes {
3-
#[at("/one/:two")]
3+
#[at("/one/{two}")]
44
One(u32),
55
}
66

0 commit comments

Comments
 (0)