From ee8651005356b49d134890d23023fc35deb84f88 Mon Sep 17 00:00:00 2001 From: Matt Yan Date: Fri, 10 Apr 2026 00:59:08 +0900 Subject: [PATCH 1/8] feat: support break and continue in for loops --- packages/yew-macro/src/html_tree/html_for.rs | 7 +++--- packages/yew-macro/src/html_tree/mod.rs | 23 ++++++++++++++++- .../yew-macro/tests/html_macro/for-fail.rs | 4 --- .../tests/html_macro/for-fail.stderr | 25 ++++++------------- .../yew-macro/tests/html_macro/for-pass.rs | 20 +++++++++++++++ 5 files changed, 52 insertions(+), 27 deletions(-) diff --git a/packages/yew-macro/src/html_tree/html_for.rs b/packages/yew-macro/src/html_tree/html_for.rs index a04de7de6bc..e3c0bc2b1eb 100644 --- a/packages/yew-macro/src/html_tree/html_for.rs +++ b/packages/yew-macro/src/html_tree/html_for.rs @@ -130,10 +130,9 @@ impl ToTokens for HtmlFor { tokens.extend(quote!({ let mut #acc = ::std::vec::Vec::<::yew::virtual_dom::VNode>::new(); - ::std::iter::Iterator::for_each( - ::std::iter::IntoIterator::into_iter(#iter), - |#pat| { #(#let_stmts)* #alloc_opt; #(#body);* } - ); + for #pat in #iter { + #(#let_stmts)* #alloc_opt; #(#body);* + } #vlist_gen })) } diff --git a/packages/yew-macro/src/html_tree/mod.rs b/packages/yew-macro/src/html_tree/mod.rs index 0e08d94cbd8..c0c78d4cdad 100644 --- a/packages/yew-macro/src/html_tree/mod.rs +++ b/packages/yew-macro/src/html_tree/mod.rs @@ -52,6 +52,8 @@ pub enum HtmlType { If, For, Match, + Break, + Continue, Empty, } @@ -64,6 +66,8 @@ pub enum HtmlTree { For(Box), Match(Box), Node(Box), + Break(Token![break]), + Continue(Token![continue]), Empty, } @@ -80,6 +84,8 @@ impl Parse for HtmlTree { HtmlType::If => Self::If(Box::new(input.parse()?)), HtmlType::For => Self::For(Box::new(input.parse()?)), HtmlType::Match => Self::Match(Box::new(input.parse()?)), + HtmlType::Break => Self::Break(input.parse()?), + HtmlType::Continue => Self::Continue(input.parse()?), }) } } @@ -113,6 +119,14 @@ impl HtmlTree { Some(HtmlType::For) } else if HtmlMatch::peek(cursor).is_some() { Some(HtmlType::Match) + } else if cursor.ident().map(|(i, _)| i == "break").unwrap_or(false) { + Some(HtmlType::Break) + } else if cursor + .ident() + .map(|(i, _)| i == "continue") + .unwrap_or(false) + { + Some(HtmlType::Continue) } else if input.peek(Token![<]) { let _lt: Token![<] = input.parse().ok()?; @@ -163,6 +177,8 @@ impl ToTokens for HtmlTree { Self::For(block) => block.to_tokens(tokens), Self::Match(block) => block.to_tokens(tokens), Self::Node(node) => node.to_tokens(tokens), + Self::Break(token) => token.to_tokens(tokens), + Self::Continue(token) => token.to_tokens(tokens), } } } @@ -428,7 +444,12 @@ impl HtmlChildrenTree { HtmlNode::Expression(_) => None, }; } - HtmlTree::If(_) | HtmlTree::For(_) | HtmlTree::Match(_) | HtmlTree::Empty => { + HtmlTree::If(_) + | HtmlTree::For(_) + | HtmlTree::Match(_) + | HtmlTree::Break(_) + | HtmlTree::Continue(_) + | HtmlTree::Empty => { return Some(false); } } diff --git a/packages/yew-macro/tests/html_macro/for-fail.rs b/packages/yew-macro/tests/html_macro/for-fail.rs index 44c2dd4efb0..3d62abae132 100644 --- a/packages/yew-macro/tests/html_macro/for-fail.rs +++ b/packages/yew-macro/tests/html_macro/for-fail.rs @@ -10,10 +10,6 @@ fn main() { {x} }}; - _ = ::yew::html!{for _ in 0 .. 10 { - {break} - }}; - _ = ::yew::html!{for _ in 0 .. 10 {
}}; diff --git a/packages/yew-macro/tests/html_macro/for-fail.stderr b/packages/yew-macro/tests/html_macro/for-fail.stderr index c4b9453d1d5..a30abc1fbee 100644 --- a/packages/yew-macro/tests/html_macro/for-fail.stderr +++ b/packages/yew-macro/tests/html_macro/for-fail.stderr @@ -24,42 +24,31 @@ error: unexpected end of input, expected curly braces error: duplicate key for a node in a `for`-loop this will create elements with duplicate keys if the loop iterates more than once - --> tests/html_macro/for-fail.rs:18:18 + --> tests/html_macro/for-fail.rs:14:18 | -18 |
+14 |
| ^^^^^^^^^^^ error: duplicate key for a node in a `for`-loop this will create elements with duplicate keys if the loop iterates more than once - --> tests/html_macro/for-fail.rs:22:19 + --> tests/html_macro/for-fail.rs:18:19 | -22 |
+18 |
| ^^^^ error: unnecessary `<>...`. Children can be placed directly in the body - --> tests/html_macro/for-fail.rs:27:9 + --> tests/html_macro/for-fail.rs:23:9 | -27 | <>{"a"}{"b"} +23 | <>{"a"}{"b"} | ^^ -error[E0267]: `break` inside of a closure - --> tests/html_macro/for-fail.rs:14:16 - | -13 | _ = ::yew::html!{for _ in 0 .. 10 { - | _________- -14 | | {break} - | | ^^^^^ cannot `break` inside of a closure -15 | | }}; - | |______- enclosing closure - error[E0308]: mismatched types --> tests/html_macro/for-fail.rs:9:26 | 9 | _ = ::yew::html!{for (x, y) in 0 .. 10 { - | ^^^^^^ + | ^^^^^^ ------- this is an iterator with items of type `{integer}` | | | expected integer, found `(_, _)` - | expected due to this | = note: expected type `{integer}` found tuple `(_, _)` diff --git a/packages/yew-macro/tests/html_macro/for-pass.rs b/packages/yew-macro/tests/html_macro/for-pass.rs index 69b315f04f9..f82f588bdc0 100644 --- a/packages/yew-macro/tests/html_macro/for-pass.rs +++ b/packages/yew-macro/tests/html_macro/for-pass.rs @@ -118,4 +118,24 @@ fn main() {
} }; + + // break in for loop + _ = ::yew::html!{ + for i in 0..10 { + if i > 5 { + break + } + {i} + } + }; + + // continue in for loop + _ = ::yew::html!{ + for i in 0..10 { + if i % 2 == 0 { + continue + } + {i} + } + }; } From e60aab549bc711691cb5fec476c45043af5e3057 Mon Sep 17 00:00:00 2001 From: "Matt \"Siyuan\" Yan" Date: Tue, 21 Apr 2026 17:41:13 +0900 Subject: [PATCH 2/8] feat: while loop support and website docs --- packages/yew-macro/src/html_tree/html_for.rs | 2 +- .../yew-macro/src/html_tree/html_while.rs | 145 +++++++++++++++++ packages/yew-macro/src/html_tree/mod.rs | 9 + .../yew-macro/tests/html_macro/while-fail.rs | 17 ++ .../tests/html_macro/while-fail.stderr | 33 ++++ .../yew-macro/tests/html_macro/while-pass.rs | 154 ++++++++++++++++++ packages/yew/tests/html_for.rs | 86 ++++++++++ packages/yew/tests/html_while.rs | 132 +++++++++++++++ website/docs/concepts/html/lists.mdx | 70 +++++++- .../current/concepts/html/lists.mdx | 64 +++++++- .../current/concepts/html/lists.mdx | 64 +++++++- .../current/concepts/html/lists.mdx | 64 +++++++- 12 files changed, 823 insertions(+), 17 deletions(-) create mode 100644 packages/yew-macro/src/html_tree/html_while.rs create mode 100644 packages/yew-macro/tests/html_macro/while-fail.rs create mode 100644 packages/yew-macro/tests/html_macro/while-fail.stderr create mode 100644 packages/yew-macro/tests/html_macro/while-pass.rs create mode 100644 packages/yew/tests/html_for.rs create mode 100644 packages/yew/tests/html_while.rs diff --git a/packages/yew-macro/src/html_tree/html_for.rs b/packages/yew-macro/src/html_tree/html_for.rs index 62b2da132c0..92cca8137cf 100644 --- a/packages/yew-macro/src/html_tree/html_for.rs +++ b/packages/yew-macro/src/html_tree/html_for.rs @@ -11,7 +11,7 @@ use crate::PeekValue; use crate::html_tree::HtmlTree; /// Determines if an expression is guaranteed to always return the same value anywhere. -fn is_contextless_pure(expr: &Expr) -> bool { +pub(super) fn is_contextless_pure(expr: &Expr) -> bool { match expr { Expr::Lit(_) => true, Expr::Path(path) => path.path.get_ident().is_none(), diff --git a/packages/yew-macro/src/html_tree/html_while.rs b/packages/yew-macro/src/html_tree/html_while.rs new file mode 100644 index 00000000000..b8a62999e43 --- /dev/null +++ b/packages/yew-macro/src/html_tree/html_while.rs @@ -0,0 +1,145 @@ +use proc_macro2::{Ident, TokenStream}; +use quote::{ToTokens, quote}; +use syn::buffer::Cursor; +use syn::parse::{Parse, ParseStream}; +use syn::spanned::Spanned; +use syn::token::While; +use syn::{Expr, Local, Stmt, Token, braced}; + +use super::{HtmlChildrenTree, ToNodeIterator}; +use crate::PeekValue; +use crate::html_tree::HtmlTree; +use crate::html_tree::html_for::is_contextless_pure; + +pub struct HtmlWhile { + cond: Box, + let_stmts: Vec, + body: HtmlChildrenTree, + deprecations: TokenStream, +} + +impl PeekValue<()> for HtmlWhile { + fn peek(cursor: Cursor) -> Option<()> { + let (ident, _) = cursor.ident()?; + (ident == "while").then_some(()) + } +} + +impl Parse for HtmlWhile { + fn parse(input: ParseStream) -> syn::Result { + While::parse(input)?; + let cond = Box::new(input.call(Expr::parse_without_eager_brace)?); + match &*cond { + Expr::Block(syn::ExprBlock { block, .. }) if block.stmts.is_empty() => { + return Err(syn::Error::new( + cond.span(), + "missing condition for `while` expression", + )); + } + _ => {} + } + if input.is_empty() { + return Err(syn::Error::new( + cond.span(), + "this `while` expression has a condition, but no block", + )); + } + + let body_stream; + braced!(body_stream in input); + + let mut let_stmts = Vec::new(); + while body_stream.peek(Token![let]) { + let stmt: Stmt = body_stream.parse()?; + match stmt { + Stmt::Local(local) => let_stmts.push(local), + _ => unreachable!("peeked Token![let] but parsed non-local statement"), + } + } + + let body = HtmlChildrenTree::parse_delimited_with_nodes(&body_stream)?; + let deprecations = super::check_unnecessary_fragment(&body); + // TODO: more concise code by using if-let guards (MSRV 1.95) + for child in body.0.iter() { + let HtmlTree::Element(element) = child else { + continue; + }; + + let Some(key) = &element.props.special.key else { + continue; + }; + + if is_contextless_pure(&key.value) { + return Err(syn::Error::new( + key.value.span(), + "duplicate key for a node in a `while`-loop\nthis will create elements with \ + duplicate keys if the loop iterates more than once", + )); + } + } + Ok(Self { + cond, + let_stmts, + body, + deprecations, + }) + } +} + +impl ToTokens for HtmlWhile { + fn to_tokens(&self, tokens: &mut TokenStream) { + let Self { + cond, + let_stmts, + body, + deprecations, + } = self; + let acc = Ident::new("__yew_v", cond.span()); + + let alloc_opt = body + .size_hint() + .filter(|&size| size > 1) // explicitly reserving space for 1 more element is redundant + .map(|size| quote!( #acc.reserve(#size) )); + + let vlist_gen = match body.fully_keyed() { + Some(true) => quote! { + ::yew::virtual_dom::VList::__macro_new( + #acc, + ::std::option::Option::None, + ::yew::virtual_dom::FullyKeyedState::KnownFullyKeyed + ) + }, + Some(false) => quote! { + ::yew::virtual_dom::VList::__macro_new( + #acc, + ::std::option::Option::None, + ::yew::virtual_dom::FullyKeyedState::KnownMissingKeys + ) + }, + None => quote! { + ::yew::virtual_dom::VList::with_children(#acc, ::std::option::Option::None) + }, + }; + + let body = body + .0 + .iter() + .map(|child| match child.to_node_iterator_stream() { + Some(child) => { + quote!( #acc.extend(#child) ) + } + _ => { + quote!( #acc.push(::std::convert::Into::into(#child)) ) + } + }); + + tokens.extend(quote!({ + #deprecations + let mut #acc = ::std::vec::Vec::<::yew::virtual_dom::VNode>::new(); + while #cond { + #(#let_stmts)* #alloc_opt; #(#body);* + } + #vlist_gen + })) + } +} diff --git a/packages/yew-macro/src/html_tree/mod.rs b/packages/yew-macro/src/html_tree/mod.rs index 7aa319c64e3..0033c8a9ad3 100644 --- a/packages/yew-macro/src/html_tree/mod.rs +++ b/packages/yew-macro/src/html_tree/mod.rs @@ -18,6 +18,7 @@ mod html_iterable; mod html_list; mod html_match; mod html_node; +mod html_while; mod lint; mod tag; @@ -34,6 +35,7 @@ use tag::TagTokens; use self::html_block::BlockContent; use self::html_for::HtmlFor; use self::html_match::HtmlMatch; +use self::html_while::HtmlWhile; pub enum HtmlType { Block, @@ -42,6 +44,7 @@ pub enum HtmlType { Element, If, For, + While, Match, Break, Continue, @@ -55,6 +58,7 @@ pub enum HtmlTree { Element(Box), If(Box), For(Box), + While(Box), Match(Box), Node(Box), Break(Token![break]), @@ -74,6 +78,7 @@ impl Parse for HtmlTree { HtmlType::List => Self::List(Box::new(input.parse()?)), HtmlType::If => Self::If(Box::new(input.parse()?)), HtmlType::For => Self::For(Box::new(input.parse()?)), + HtmlType::While => Self::While(Box::new(input.parse()?)), HtmlType::Match => Self::Match(Box::new(input.parse()?)), HtmlType::Break => Self::Break(input.parse()?), HtmlType::Continue => Self::Continue(input.parse()?), @@ -108,6 +113,8 @@ impl HtmlTree { Some(HtmlType::If) } else if HtmlFor::peek(cursor).is_some() { Some(HtmlType::For) + } else if HtmlWhile::peek(cursor).is_some() { + Some(HtmlType::While) } else if HtmlMatch::peek(cursor).is_some() { Some(HtmlType::Match) } else if cursor.ident().map(|(i, _)| i == "break").unwrap_or(false) { @@ -166,6 +173,7 @@ impl ToTokens for HtmlTree { Self::Block(block) => block.to_tokens(tokens), Self::If(block) => block.to_tokens(tokens), Self::For(block) => block.to_tokens(tokens), + Self::While(block) => block.to_tokens(tokens), Self::Match(block) => block.to_tokens(tokens), Self::Node(node) => node.to_tokens(tokens), Self::Break(token) => token.to_tokens(tokens), @@ -465,6 +473,7 @@ impl HtmlChildrenTree { } HtmlTree::If(_) | HtmlTree::For(_) + | HtmlTree::While(_) | HtmlTree::Match(_) | HtmlTree::Break(_) | HtmlTree::Continue(_) diff --git a/packages/yew-macro/tests/html_macro/while-fail.rs b/packages/yew-macro/tests/html_macro/while-fail.rs new file mode 100644 index 00000000000..bb79634eddd --- /dev/null +++ b/packages/yew-macro/tests/html_macro/while-fail.rs @@ -0,0 +1,17 @@ +mod smth { + const KEY: u32 = 42; +} + +fn main() { + _ = ::yew::html!{while}; + _ = ::yew::html!{while true}; + _ = ::yew::html!{while {} {
}}; + + _ = ::yew::html!{while true { +
+ }}; + + _ = ::yew::html!{while true { +
+ }}; +} diff --git a/packages/yew-macro/tests/html_macro/while-fail.stderr b/packages/yew-macro/tests/html_macro/while-fail.stderr new file mode 100644 index 00000000000..04db05c91a8 --- /dev/null +++ b/packages/yew-macro/tests/html_macro/while-fail.stderr @@ -0,0 +1,33 @@ +error: unexpected end of input, expected an expression + --> tests/html_macro/while-fail.rs:6:9 + | +6 | _ = ::yew::html!{while}; + | ^^^^^^^^^^^^^^^^^^^ + | + = note: this error originates in the macro `::yew::html` (in Nightly builds, run with -Z macro-backtrace for more info) + +error: this `while` expression has a condition, but no block + --> tests/html_macro/while-fail.rs:7:28 + | +7 | _ = ::yew::html!{while true}; + | ^^^^ + +error: missing condition for `while` expression + --> tests/html_macro/while-fail.rs:8:28 + | +8 | _ = ::yew::html!{while {} {
}}; + | ^^ + +error: duplicate key for a node in a `while`-loop + this will create elements with duplicate keys if the loop iterates more than once + --> tests/html_macro/while-fail.rs:11:18 + | +11 |
+ | ^^^^^^^^^^^ + +error: duplicate key for a node in a `while`-loop + this will create elements with duplicate keys if the loop iterates more than once + --> tests/html_macro/while-fail.rs:15:19 + | +15 |
+ | ^^^^ diff --git a/packages/yew-macro/tests/html_macro/while-pass.rs b/packages/yew-macro/tests/html_macro/while-pass.rs new file mode 100644 index 00000000000..fa7789cb9c4 --- /dev/null +++ b/packages/yew-macro/tests/html_macro/while-pass.rs @@ -0,0 +1,154 @@ +#![no_implicit_prelude] + +// Shadow primitives +#[allow(non_camel_case_types)] +pub struct bool; +#[allow(non_camel_case_types)] +pub struct char; +#[allow(non_camel_case_types)] +pub struct f32; +#[allow(non_camel_case_types)] +pub struct f64; +#[allow(non_camel_case_types)] +pub struct i128; +#[allow(non_camel_case_types)] +pub struct i16; +#[allow(non_camel_case_types)] +pub struct i32; +#[allow(non_camel_case_types)] +pub struct i64; +#[allow(non_camel_case_types)] +pub struct i8; +#[allow(non_camel_case_types)] +pub struct isize; +#[allow(non_camel_case_types)] +pub struct str; +#[allow(non_camel_case_types)] +pub struct u128; +#[allow(non_camel_case_types)] +pub struct u16; +#[allow(non_camel_case_types)] +pub struct u32; +#[allow(non_camel_case_types)] +pub struct u64; +#[allow(non_camel_case_types)] +pub struct u8; +#[allow(non_camel_case_types)] +pub struct usize; + +fn main() { + // Basic while with counter bumped via a let-bound post-increment block + _ = { + let mut i: ::std::primitive::i32 = 0; + ::yew::html! { + while i < 5 { + let current = { let c = i; i += 1; c }; + {current} + } + } + }; + + // while let destructuring an iterator + _ = { + let mut it = ::std::iter::IntoIterator::into_iter(0..5); + ::yew::html! { + while let ::std::option::Option::Some(v) = ::std::iter::Iterator::next(&mut it) { + {v} + } + } + }; + + // while let with pattern destructuring + _ = { + let mut it = ::std::iter::IntoIterator::into_iter([(1, "a"), (2, "b")]); + ::yew::html! { + while let ::std::option::Option::Some((n, s)) = ::std::iter::Iterator::next(&mut it) { + {n} + {s} + } + } + }; + + // Multiple let bindings before nodes + _ = { + let mut i: ::std::primitive::i32 = 0; + ::yew::html! { + while i < 5 { + let current = { let c = i; i += 1; c }; + let doubled = current * 2; + let label = "item"; + {label} + {doubled} + } + } + }; + + // Let binding with explicit type annotation + _ = { + let mut i: ::std::primitive::i32 = 0; + ::yew::html! { + while i < 5 { + let x: ::std::primitive::i32 = { let c = i * 3; i += 1; c }; + {x} + } + } + }; + + // break in while + _ = { + let mut i: ::std::primitive::i32 = 0; + ::yew::html! { + while i < 100 { + let current = { let c = i; i += 1; c }; + if current > 5 { + break + } + {current} + } + } + }; + + // continue in while + _ = { + let mut i: ::std::primitive::i32 = 0; + ::yew::html! { + while i < 10 { + let current = { let c = i; i += 1; c }; + if current % 2 == 0 { + continue + } + {current} + } + } + }; + + // while-let with break and continue + _ = { + let mut it = ::std::iter::IntoIterator::into_iter(0..10); + ::yew::html! { + while let ::std::option::Option::Some(v) = ::std::iter::Iterator::next(&mut it) { + if v >= 6 { + break + } + if v % 2 == 0 { + continue + } + {v} + } + } + }; + + // Nested for inside while + _ = { + let mut i: ::std::primitive::i32 = 0; + ::yew::html! { + while i < 3 { + let row = { let c = i; i += 1; c }; + for col in 0..3 { + {row} + {col} + } + } + } + }; +} diff --git a/packages/yew/tests/html_for.rs b/packages/yew/tests/html_for.rs new file mode 100644 index 00000000000..3feab78b2ec --- /dev/null +++ b/packages/yew/tests/html_for.rs @@ -0,0 +1,86 @@ +#![cfg(all(target_arch = "wasm32", not(target_os = "wasi")))] + +mod common; + +use common::obtain_result; +use wasm_bindgen_test::*; +use yew::prelude::*; +use yew::scheduler; + +wasm_bindgen_test::wasm_bindgen_test_configure!(run_in_browser); + +async fn render_and_read>() -> String { + yew::Renderer::::with_root(gloo::utils::document().get_element_by_id("output").unwrap()) + .render(); + scheduler::flush().await; + obtain_result() +} + +#[wasm_bindgen_test] +async fn for_break_emits_prefix() { + #[component] + fn App() -> Html { + html! { +
+ for i in 0..10 { + if i > 5 { + break + } + {i} + } +
+ } + } + + assert_eq!( + render_and_read::().await, + "012345" + ); +} + +#[wasm_bindgen_test] +async fn for_continue_skips_matching() { + #[component] + fn App() -> Html { + html! { +
+ for i in 0..6 { + if i % 2 == 0 { + continue + } + {i} + } +
+ } + } + + assert_eq!( + render_and_read::().await, + "135" + ); +} + +#[wasm_bindgen_test] +async fn for_break_and_continue_together() { + #[component] + fn App() -> Html { + html! { +
+ for i in 0..100 { + if i >= 8 { + break + } + if i % 3 == 0 { + continue + } + {i} + } +
+ } + } + + assert_eq!( + render_and_read::().await, + "12457" + ); +} diff --git a/packages/yew/tests/html_while.rs b/packages/yew/tests/html_while.rs new file mode 100644 index 00000000000..69e323a05ff --- /dev/null +++ b/packages/yew/tests/html_while.rs @@ -0,0 +1,132 @@ +#![cfg(all(target_arch = "wasm32", not(target_os = "wasi")))] + +mod common; + +use common::obtain_result; +use wasm_bindgen_test::*; +use yew::prelude::*; +use yew::scheduler; + +wasm_bindgen_test::wasm_bindgen_test_configure!(run_in_browser); + +async fn render_and_read>() -> String { + yew::Renderer::::with_root(gloo::utils::document().get_element_by_id("output").unwrap()) + .render(); + scheduler::flush().await; + obtain_result() +} + +#[wasm_bindgen_test] +async fn while_iterates_until_false() { + #[component] + fn App() -> Html { + let mut i: i32 = 0; + html! { +
+ while i < 5 { + let current = { let c = i; i += 1; c }; + {current} + } +
+ } + } + + assert_eq!( + render_and_read::().await, + "01234" + ); +} + +#[wasm_bindgen_test] +async fn while_break_exits_early() { + #[component] + fn App() -> Html { + let mut i: i32 = 0; + html! { +
+ while i < 100 { + let current = { let c = i; i += 1; c }; + if current > 5 { + break + } + {current} + } +
+ } + } + + assert_eq!( + render_and_read::().await, + "012345" + ); +} + +#[wasm_bindgen_test] +async fn while_continue_skips_matching() { + #[component] + fn App() -> Html { + let mut i: i32 = 0; + html! { +
+ while i < 6 { + let current = { let c = i; i += 1; c }; + if current % 2 == 0 { + continue + } + {current} + } +
+ } + } + + assert_eq!( + render_and_read::().await, + "135" + ); +} + +#[wasm_bindgen_test] +async fn while_let_iterates_option_some() { + #[component] + fn App() -> Html { + let mut it = (0..4).into_iter(); + html! { +
+ while let Some(v) = it.next() { + {v} + } +
+ } + } + + assert_eq!( + render_and_read::().await, + "0123" + ); +} + +#[wasm_bindgen_test] +async fn while_let_break_and_continue_together() { + #[component] + fn App() -> Html { + let mut it = (0..100).into_iter(); + html! { +
+ while let Some(v) = it.next() { + if v >= 8 { + break + } + if v % 3 == 0 { + continue + } + {v} + } +
+ } + } + + assert_eq!( + render_and_read::().await, + "12457" + ); +} diff --git a/website/docs/concepts/html/lists.mdx b/website/docs/concepts/html/lists.mdx index abb2ce30da6..c06c173ffbf 100644 --- a/website/docs/concepts/html/lists.mdx +++ b/website/docs/concepts/html/lists.mdx @@ -7,13 +7,13 @@ import TabItem from '@theme/TabItem' ## Iterators -There are 3 ways to build HTML from iterators: +There are 4 ways to build HTML from iterators: -The main approach is to use for loops, the same for loops that already exist in Rust, but with 2 key differences: -1. Unlike standard for loops which can't return anything, for loops in `html!` are converted to a list of nodes; -2. Diverging expressions, i.e. `break`, `continue` are not allowed in the body of for loops in `html!`. +The main approach is to use for loops, the same for loops that already exist in Rust, with one +key difference: unlike standard for loops which can't return anything, for loops in `html!` are +converted to a list of nodes. ```rust use yew::prelude::*; @@ -37,6 +37,68 @@ html! {
{label}
} }; +``` + +`break` and `continue` work as in ordinary Rust loops and affect the emitted node list. +`continue` skips the current iteration without producing a node, `break` stops iteration early: + +```rust +use yew::prelude::*; + +html! { + for i in 0..10 { + if i % 2 == 0 { + continue + } + if i > 7 { + break + } + {i} + } +}; +``` + +
+ +`while` and `while let` loops work the same way as `for`, producing a list of nodes from their +body. `let` bindings, `if` blocks, and `break` / `continue` are all supported. + +Pair `while let` with an iterator for a clean iteration pattern, where `break` and `continue` +compose naturally: + +```rust +use yew::prelude::*; + +let mut items = vec!["a", "b", "c", "skip", "d"].into_iter(); + +html! { + while let Some(item) = items.next() { + if item == "skip" { + continue + } + if item.is_empty() { + break + } + {item} + } +}; +``` + +A plain `while` with a condition works too. Any state the loop needs to advance its condition +must be set up via the outer scope and updated inside the body, typically via a `let` binding +at the top of the body: + +```rust +use yew::prelude::*; + +let mut counter: i32 = 0; + +html! { + while counter < 5 { + let current = { let c = counter; counter += 1; c }; + {current} + } +}; ``` diff --git a/website/i18n/ja/docusaurus-plugin-content-docs/current/concepts/html/lists.mdx b/website/i18n/ja/docusaurus-plugin-content-docs/current/concepts/html/lists.mdx index fdfc25dc5f6..8ae264590c9 100644 --- a/website/i18n/ja/docusaurus-plugin-content-docs/current/concepts/html/lists.mdx +++ b/website/i18n/ja/docusaurus-plugin-content-docs/current/concepts/html/lists.mdx @@ -7,13 +7,11 @@ import TabItem from '@theme/TabItem' ## イテレータ -イテレータから HTML を構築する方法は 3 つあります: +イテレータから HTML を構築する方法は 4 つあります: -主なアプローチは for ループを使用することです。これは Rust に既に存在する for ループと同じですが、2 つの重要な違いがあります: -1. 通常の for ループは何も返せませんが、`html!` 内の for ループはノードのリストに変換されます。 -2. `break`、`continue` などの発散式は `html!` 内の for ループの本体では許可されていません。 +主なアプローチは for ループを使用することです。これは Rust に既に存在する for ループと同じですが、1 つの重要な違いがあります:通常の for ループは何も返せませんが、`html!` 内の for ループはノードのリストに変換されます。 ```rust use yew::prelude::*; @@ -37,6 +35,64 @@ html! {
{label}
} }; +``` + +`break` と `continue` は通常の Rust のループと同様に動作し、出力されるノードリストに影響します。 +`continue` は現在の反復でノードを生成せずにスキップし、`break` は反復を早期に終了します: + +```rust +use yew::prelude::*; + +html! { + for i in 0..10 { + if i % 2 == 0 { + continue + } + if i > 7 { + break + } + {i} + } +}; +``` + +
+ +`while` および `while let` ループは `for` と同じように動作し、本体からノードのリストを生成します。`let` バインディング、`if` ブロック、`break` / `continue` のすべてがサポートされています。 + +`while let` をイテレータと組み合わせて使うと、きれいな反復パターンになり、`break` と `continue` が自然に組み合わせられます: + +```rust +use yew::prelude::*; + +let mut items = vec!["a", "b", "c", "skip", "d"].into_iter(); + +html! { + while let Some(item) = items.next() { + if item == "skip" { + continue + } + if item.is_empty() { + break + } + {item} + } +}; +``` + +条件付きの通常の `while` も使用できます。ループが条件を進めるために必要な状態はすべて外側のスコープで用意し、本体の中で更新する必要があります。通常は本体の先頭の `let` バインディングで行います: + +```rust +use yew::prelude::*; + +let mut counter: i32 = 0; + +html! { + while counter < 5 { + let current = { let c = counter; counter += 1; c }; + {current} + } +}; ``` diff --git a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/concepts/html/lists.mdx b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/concepts/html/lists.mdx index 7cd4ec9459b..71c41c8cba9 100644 --- a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/concepts/html/lists.mdx +++ b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/concepts/html/lists.mdx @@ -7,13 +7,11 @@ import TabItem from '@theme/TabItem' ## 迭代器 -从迭代器构建 HTML 有 3 种方法: +从迭代器构建 HTML 有 4 种方法: -主要方法是使用 for 循环,与 Rust 中已有的 for 循环相同,但有 2 个关键区别: -1. 与标准 for 循环不能返回任何内容不同,`html!` 中的 for 循环会被转换为节点列表; -2. 发散表达式,即 `break`、`continue` 在 `html!` 中的 for 循环体内是不允许的。 +主要方法是使用 for 循环,与 Rust 中已有的 for 循环相同,但有一个关键区别:与标准 for 循环不能返回任何内容不同,`html!` 中的 for 循环会被转换为节点列表。 ```rust use yew::prelude::*; @@ -37,6 +35,64 @@ html! {
{label}
} }; +``` + +`break` 和 `continue` 的作用与普通 Rust 循环相同,会影响生成的节点列表。 +`continue` 会跳过当前迭代而不产生节点,`break` 会提前结束迭代: + +```rust +use yew::prelude::*; + +html! { + for i in 0..10 { + if i % 2 == 0 { + continue + } + if i > 7 { + break + } + {i} + } +}; +``` + +
+ +`while` 和 `while let` 循环的工作方式与 `for` 相同,也会从循环体中生成节点列表。`let` 绑定、`if` 块以及 `break` / `continue` 都被支持。 + +将 `while let` 与迭代器搭配使用可以得到一种清晰的迭代模式,其中 `break` 和 `continue` 可以自然地组合使用: + +```rust +use yew::prelude::*; + +let mut items = vec!["a", "b", "c", "skip", "d"].into_iter(); + +html! { + while let Some(item) = items.next() { + if item == "skip" { + continue + } + if item.is_empty() { + break + } + {item} + } +}; +``` + +带条件的普通 `while` 也可以使用。循环推进条件所需的任何状态都必须在外部作用域中设置,并在循环体内部更新,通常通过在循环体顶部添加 `let` 绑定来完成: + +```rust +use yew::prelude::*; + +let mut counter: i32 = 0; + +html! { + while counter < 5 { + let current = { let c = counter; counter += 1; c }; + {current} + } +}; ``` diff --git a/website/i18n/zh-Hant/docusaurus-plugin-content-docs/current/concepts/html/lists.mdx b/website/i18n/zh-Hant/docusaurus-plugin-content-docs/current/concepts/html/lists.mdx index b2114e57bbc..8f6ea83c056 100644 --- a/website/i18n/zh-Hant/docusaurus-plugin-content-docs/current/concepts/html/lists.mdx +++ b/website/i18n/zh-Hant/docusaurus-plugin-content-docs/current/concepts/html/lists.mdx @@ -7,13 +7,11 @@ import TabItem from '@theme/TabItem' ## 迭代器 -從迭代器建立 HTML 有 3 種方法: +從迭代器建立 HTML 有 4 種方法: -主要方法是使用 for 迴圈,與 Rust 中已有的 for 迴圈相同,但有 2 個關鍵區別: -1. 與標準 for 迴圈不能傳回任何內容不同,`html!` 中的 for 迴圈會被轉換為節點清單; -2. 發散運算式,即 `break`、`continue` 在 `html!` 中的 for 迴圈主體內是不允許的。 +主要方法是使用 for 迴圈,與 Rust 中已有的 for 迴圈相同,但有一個關鍵區別:與標準 for 迴圈不能傳回任何內容不同,`html!` 中的 for 迴圈會被轉換為節點清單。 ```rust use yew::prelude::*; @@ -37,6 +35,64 @@ html! {
{label}
} }; +``` + +`break` 和 `continue` 的作用與一般 Rust 迴圈相同,會影響生成的節點清單。 +`continue` 會跳過目前迭代而不產生節點,`break` 會提前終止迭代: + +```rust +use yew::prelude::*; + +html! { + for i in 0..10 { + if i % 2 == 0 { + continue + } + if i > 7 { + break + } + {i} + } +}; +``` + +
+ +`while` 和 `while let` 迴圈的運作方式與 `for` 相同,也會從迴圈主體中生成節點清單。`let` 綁定、`if` 區塊以及 `break` / `continue` 都受到支援。 + +將 `while let` 與迭代器搭配使用,可以得到一種清晰的迭代模式,其中 `break` 和 `continue` 可以自然地組合使用: + +```rust +use yew::prelude::*; + +let mut items = vec!["a", "b", "c", "skip", "d"].into_iter(); + +html! { + while let Some(item) = items.next() { + if item == "skip" { + continue + } + if item.is_empty() { + break + } + {item} + } +}; +``` + +帶條件的一般 `while` 也可以使用。迴圈推進條件所需的任何狀態都必須在外部作用域中設定,並在迴圈主體內部更新,通常是透過在迴圈主體頂部加入 `let` 綁定來完成: + +```rust +use yew::prelude::*; + +let mut counter: i32 = 0; + +html! { + while counter < 5 { + let current = { let c = counter; counter += 1; c }; + {current} + } +}; ``` From 1b0418784e4f8ef3a00dd217f2a564c47f412ad8 Mon Sep 17 00:00:00 2001 From: "Matt \"Siyuan\" Yan" Date: Tue, 21 Apr 2026 18:12:18 +0900 Subject: [PATCH 3/8] fix: emit break/continue as bare statements for edition 2021 compact Our MSRV is 1.85+ but users might stay on edition 2021 for various reasons This change wraps break/continue in Into::into(...) triggered the never-type fallback in editions before 2024, where unconstrained ! falls back to (), producing "(): Into is not satisfied" on edition 2021 users. Emit the keyword directly as a statement instead so the generated code is edition-agnostic. --- packages/yew-macro/src/html_tree/html_for.rs | 18 ++++------- .../yew-macro/src/html_tree/html_while.rs | 18 ++++------- packages/yew-macro/src/html_tree/mod.rs | 32 +++++++++++-------- 3 files changed, 32 insertions(+), 36 deletions(-) diff --git a/packages/yew-macro/src/html_tree/html_for.rs b/packages/yew-macro/src/html_tree/html_for.rs index 92cca8137cf..74393cdc36e 100644 --- a/packages/yew-macro/src/html_tree/html_for.rs +++ b/packages/yew-macro/src/html_tree/html_for.rs @@ -119,17 +119,13 @@ impl ToTokens for HtmlFor { }, }; - let body = body - .0 - .iter() - .map(|child| match child.to_node_iterator_stream() { - Some(child) => { - quote!( #acc.extend(#child) ) - } - _ => { - quote!( #acc.push(::std::convert::Into::into(#child)) ) - } - }); + let body = body.0.iter().map(|child| match child { + HtmlTree::Break(_) | HtmlTree::Continue(_) => quote!( #child ), + _ => match child.to_node_iterator_stream() { + Some(stream) => quote!( #acc.extend(#stream) ), + _ => quote!( #acc.push(::std::convert::Into::into(#child)) ), + }, + }); tokens.extend(quote!({ #deprecations diff --git a/packages/yew-macro/src/html_tree/html_while.rs b/packages/yew-macro/src/html_tree/html_while.rs index b8a62999e43..7bb2e168add 100644 --- a/packages/yew-macro/src/html_tree/html_while.rs +++ b/packages/yew-macro/src/html_tree/html_while.rs @@ -121,17 +121,13 @@ impl ToTokens for HtmlWhile { }, }; - let body = body - .0 - .iter() - .map(|child| match child.to_node_iterator_stream() { - Some(child) => { - quote!( #acc.extend(#child) ) - } - _ => { - quote!( #acc.push(::std::convert::Into::into(#child)) ) - } - }); + let body = body.0.iter().map(|child| match child { + HtmlTree::Break(_) | HtmlTree::Continue(_) => quote!( #child ), + _ => match child.to_node_iterator_stream() { + Some(stream) => quote!( #acc.extend(#stream) ), + _ => quote!( #acc.push(::std::convert::Into::into(#child)) ), + }, + }); tokens.extend(quote!({ #deprecations diff --git a/packages/yew-macro/src/html_tree/mod.rs b/packages/yew-macro/src/html_tree/mod.rs index 0033c8a9ad3..30442aeb3cd 100644 --- a/packages/yew-macro/src/html_tree/mod.rs +++ b/packages/yew-macro/src/html_tree/mod.rs @@ -311,7 +311,11 @@ impl HtmlChildrenTree { pub fn to_build_vec_token_stream(&self) -> TokenStream { let Self(children) = self; - if self.only_single_node_children() { + let has_divergent = children + .iter() + .any(|c| matches!(c, HtmlTree::Break(_) | HtmlTree::Continue(_))); + + if !has_divergent && self.only_single_node_children() { // optimize for the common case where all children are single nodes (only using literal // html). let children_into = children @@ -323,21 +327,21 @@ impl HtmlChildrenTree { } let vec_ident = Ident::new("__yew_v", Span::mixed_site()); - let add_children_streams = - children - .iter() - .map(|child| match child.to_node_iterator_stream() { - Some(node_iterator_stream) => { - quote! { - ::std::iter::Extend::extend(&mut #vec_ident, #node_iterator_stream); - } + let add_children_streams = children.iter().map(|child| match child { + HtmlTree::Break(_) | HtmlTree::Continue(_) => quote!( #child; ), + _ => match child.to_node_iterator_stream() { + Some(node_iterator_stream) => { + quote! { + ::std::iter::Extend::extend(&mut #vec_ident, #node_iterator_stream); } - _ => { - quote_spanned! {child.span()=> - #vec_ident.push(::std::convert::Into::into(#child)); - } + } + _ => { + quote_spanned! {child.span()=> + #vec_ident.push(::std::convert::Into::into(#child)); } - }); + } + }, + }); quote! { { From 6643760e3d4b08273a0bfdd1a929ba34b13349f6 Mon Sep 17 00:00:00 2001 From: "Matt \"Siyuan\" Yan" Date: Tue, 21 Apr 2026 18:27:33 +0900 Subject: [PATCH 4/8] feat: dedup while and for loop code and admit label limitation --- packages/yew-macro/src/html_tree/html_for.rs | 97 ++------------- packages/yew-macro/src/html_tree/html_loop.rs | 114 ++++++++++++++++++ .../yew-macro/src/html_tree/html_while.rs | 89 ++------------ packages/yew-macro/src/html_tree/mod.rs | 1 + website/docs/concepts/html/lists.mdx | 4 + .../current/concepts/html/lists.mdx | 2 + .../current/concepts/html/lists.mdx | 2 + .../current/concepts/html/lists.mdx | 2 + 8 files changed, 151 insertions(+), 160 deletions(-) create mode 100644 packages/yew-macro/src/html_tree/html_loop.rs diff --git a/packages/yew-macro/src/html_tree/html_for.rs b/packages/yew-macro/src/html_tree/html_for.rs index 74393cdc36e..2742c764001 100644 --- a/packages/yew-macro/src/html_tree/html_for.rs +++ b/packages/yew-macro/src/html_tree/html_for.rs @@ -1,23 +1,14 @@ -use proc_macro2::{Ident, TokenStream}; +use proc_macro2::TokenStream; use quote::{ToTokens, quote}; use syn::buffer::Cursor; use syn::parse::{Parse, ParseStream}; use syn::spanned::Spanned; use syn::token::{For, In}; -use syn::{Expr, Local, Pat, Stmt, Token, braced}; +use syn::{Expr, Local, Pat, braced}; -use super::{HtmlChildrenTree, ToNodeIterator}; +use super::HtmlChildrenTree; +use super::html_loop::{emit_loop, parse_loop_body}; use crate::PeekValue; -use crate::html_tree::HtmlTree; - -/// Determines if an expression is guaranteed to always return the same value anywhere. -pub(super) fn is_contextless_pure(expr: &Expr) -> bool { - match expr { - Expr::Lit(_) => true, - Expr::Path(path) => path.path.get_ident().is_none(), - _ => false, - } -} pub struct HtmlFor { pat: Pat, @@ -44,35 +35,8 @@ impl Parse for HtmlFor { let body_stream; braced!(body_stream in input); - let mut let_stmts = Vec::new(); - while body_stream.peek(Token![let]) { - let stmt: Stmt = body_stream.parse()?; - match stmt { - Stmt::Local(local) => let_stmts.push(local), - _ => unreachable!("peeked Token![let] but parsed non-local statement"), - } - } + let (let_stmts, body, deprecations) = parse_loop_body(&body_stream, "for")?; - let body = HtmlChildrenTree::parse_delimited_with_nodes(&body_stream)?; - let deprecations = super::check_unnecessary_fragment(&body); - // TODO: more concise code by using if-let guards (MSRV 1.95) - for child in body.0.iter() { - let HtmlTree::Element(element) = child else { - continue; - }; - - let Some(key) = &element.props.special.key else { - continue; - }; - - if is_contextless_pure(&key.value) { - return Err(syn::Error::new( - key.value.span(), - "duplicate key for a node in a `for`-loop\nthis will create elements with \ - duplicate keys if the loop iterates more than once", - )); - } - } Ok(Self { pat, iter, @@ -92,48 +56,13 @@ impl ToTokens for HtmlFor { body, deprecations, } = self; - let acc = Ident::new("__yew_v", iter.span()); - - let alloc_opt = body - .size_hint() - .filter(|&size| size > 1) // explicitly reserving space for 1 more element is redundant - .map(|size| quote!( #acc.reserve(#size) )); - - let vlist_gen = match body.fully_keyed() { - Some(true) => quote! { - ::yew::virtual_dom::VList::__macro_new( - #acc, - ::std::option::Option::None, - ::yew::virtual_dom::FullyKeyedState::KnownFullyKeyed - ) - }, - Some(false) => quote! { - ::yew::virtual_dom::VList::__macro_new( - #acc, - ::std::option::Option::None, - ::yew::virtual_dom::FullyKeyedState::KnownMissingKeys - ) - }, - None => quote! { - ::yew::virtual_dom::VList::with_children(#acc, ::std::option::Option::None) - }, - }; - - let body = body.0.iter().map(|child| match child { - HtmlTree::Break(_) | HtmlTree::Continue(_) => quote!( #child ), - _ => match child.to_node_iterator_stream() { - Some(stream) => quote!( #acc.extend(#stream) ), - _ => quote!( #acc.push(::std::convert::Into::into(#child)) ), - }, - }); - - tokens.extend(quote!({ - #deprecations - let mut #acc = ::std::vec::Vec::<::yew::virtual_dom::VNode>::new(); - for #pat in #iter { - #(#let_stmts)* #alloc_opt; #(#body);* - } - #vlist_gen - })) + let header = quote!(for #pat in #iter); + tokens.extend(emit_loop( + header, + iter.span(), + let_stmts, + body, + deprecations, + )); } } diff --git a/packages/yew-macro/src/html_tree/html_loop.rs b/packages/yew-macro/src/html_tree/html_loop.rs new file mode 100644 index 00000000000..953d6f76fce --- /dev/null +++ b/packages/yew-macro/src/html_tree/html_loop.rs @@ -0,0 +1,114 @@ +use proc_macro2::{Ident, Span, TokenStream}; +use quote::quote; +use syn::parse::ParseStream; +use syn::spanned::Spanned; +use syn::{Expr, Local, Stmt, Token}; + +use super::{HtmlChildrenTree, HtmlTree, ToNodeIterator}; + +/// Determines if an expression is guaranteed to always return the same value anywhere. +pub(super) fn is_contextless_pure(expr: &Expr) -> bool { + match expr { + Expr::Lit(_) => true, + Expr::Path(path) => path.path.get_ident().is_none(), + _ => false, + } +} + +/// Parse leading `let` bindings from a loop body, then the remaining children. +/// Also runs duplicate-key detection keyed to `loop_kind` (e.g. "for", "while"). +pub(super) fn parse_loop_body( + body_stream: ParseStream, + loop_kind: &str, +) -> syn::Result<(Vec, HtmlChildrenTree, TokenStream)> { + let mut let_stmts = Vec::new(); + while body_stream.peek(Token![let]) { + let stmt: Stmt = body_stream.parse()?; + match stmt { + Stmt::Local(local) => let_stmts.push(local), + _ => unreachable!("peeked Token![let] but parsed non-local statement"), + } + } + + let body = HtmlChildrenTree::parse_delimited_with_nodes(body_stream)?; + let deprecations = super::check_unnecessary_fragment(&body); + // TODO: more concise code by using if-let guards (MSRV 1.95) + for child in body.0.iter() { + let HtmlTree::Element(element) = child else { + continue; + }; + + let Some(key) = &element.props.special.key else { + continue; + }; + + if is_contextless_pure(&key.value) { + return Err(syn::Error::new( + key.value.span(), + format!( + "duplicate key for a node in a `{loop_kind}`-loop\nthis will create elements \ + with duplicate keys if the loop iterates more than once" + ), + )); + } + } + + Ok((let_stmts, body, deprecations)) +} + +/// Emit a loop that accumulates its body children into a `VList`. +/// +/// `loop_header` is the native Rust loop syntax without its body, e.g. +/// `for #pat in #iter` or `while #cond`. `span` is used to place the internal +/// accumulator identifier. +pub(super) fn emit_loop( + loop_header: TokenStream, + span: Span, + let_stmts: &[Local], + body: &HtmlChildrenTree, + deprecations: &TokenStream, +) -> TokenStream { + let acc = Ident::new("__yew_v", span); + + let alloc_opt = body + .size_hint() + .filter(|&size| size > 1) // explicitly reserving space for 1 more element is redundant + .map(|size| quote!( #acc.reserve(#size) )); + + let vlist_gen = match body.fully_keyed() { + Some(true) => quote! { + ::yew::virtual_dom::VList::__macro_new( + #acc, + ::std::option::Option::None, + ::yew::virtual_dom::FullyKeyedState::KnownFullyKeyed + ) + }, + Some(false) => quote! { + ::yew::virtual_dom::VList::__macro_new( + #acc, + ::std::option::Option::None, + ::yew::virtual_dom::FullyKeyedState::KnownMissingKeys + ) + }, + None => quote! { + ::yew::virtual_dom::VList::with_children(#acc, ::std::option::Option::None) + }, + }; + + let body_streams = body.0.iter().map(|child| match child { + HtmlTree::Break(_) | HtmlTree::Continue(_) => quote!( #child ), + _ => match child.to_node_iterator_stream() { + Some(stream) => quote!( #acc.extend(#stream) ), + _ => quote!( #acc.push(::std::convert::Into::into(#child)) ), + }, + }); + + quote!({ + #deprecations + let mut #acc = ::std::vec::Vec::<::yew::virtual_dom::VNode>::new(); + #loop_header { + #(#let_stmts)* #alloc_opt; #(#body_streams);* + } + #vlist_gen + }) +} diff --git a/packages/yew-macro/src/html_tree/html_while.rs b/packages/yew-macro/src/html_tree/html_while.rs index 7bb2e168add..7cb347e6fa7 100644 --- a/packages/yew-macro/src/html_tree/html_while.rs +++ b/packages/yew-macro/src/html_tree/html_while.rs @@ -1,15 +1,14 @@ -use proc_macro2::{Ident, TokenStream}; +use proc_macro2::TokenStream; use quote::{ToTokens, quote}; use syn::buffer::Cursor; use syn::parse::{Parse, ParseStream}; use syn::spanned::Spanned; use syn::token::While; -use syn::{Expr, Local, Stmt, Token, braced}; +use syn::{Expr, Local, braced}; -use super::{HtmlChildrenTree, ToNodeIterator}; +use super::HtmlChildrenTree; +use super::html_loop::{emit_loop, parse_loop_body}; use crate::PeekValue; -use crate::html_tree::HtmlTree; -use crate::html_tree::html_for::is_contextless_pure; pub struct HtmlWhile { cond: Box, @@ -48,35 +47,8 @@ impl Parse for HtmlWhile { let body_stream; braced!(body_stream in input); - let mut let_stmts = Vec::new(); - while body_stream.peek(Token![let]) { - let stmt: Stmt = body_stream.parse()?; - match stmt { - Stmt::Local(local) => let_stmts.push(local), - _ => unreachable!("peeked Token![let] but parsed non-local statement"), - } - } - - let body = HtmlChildrenTree::parse_delimited_with_nodes(&body_stream)?; - let deprecations = super::check_unnecessary_fragment(&body); - // TODO: more concise code by using if-let guards (MSRV 1.95) - for child in body.0.iter() { - let HtmlTree::Element(element) = child else { - continue; - }; - - let Some(key) = &element.props.special.key else { - continue; - }; + let (let_stmts, body, deprecations) = parse_loop_body(&body_stream, "while")?; - if is_contextless_pure(&key.value) { - return Err(syn::Error::new( - key.value.span(), - "duplicate key for a node in a `while`-loop\nthis will create elements with \ - duplicate keys if the loop iterates more than once", - )); - } - } Ok(Self { cond, let_stmts, @@ -94,48 +66,13 @@ impl ToTokens for HtmlWhile { body, deprecations, } = self; - let acc = Ident::new("__yew_v", cond.span()); - - let alloc_opt = body - .size_hint() - .filter(|&size| size > 1) // explicitly reserving space for 1 more element is redundant - .map(|size| quote!( #acc.reserve(#size) )); - - let vlist_gen = match body.fully_keyed() { - Some(true) => quote! { - ::yew::virtual_dom::VList::__macro_new( - #acc, - ::std::option::Option::None, - ::yew::virtual_dom::FullyKeyedState::KnownFullyKeyed - ) - }, - Some(false) => quote! { - ::yew::virtual_dom::VList::__macro_new( - #acc, - ::std::option::Option::None, - ::yew::virtual_dom::FullyKeyedState::KnownMissingKeys - ) - }, - None => quote! { - ::yew::virtual_dom::VList::with_children(#acc, ::std::option::Option::None) - }, - }; - - let body = body.0.iter().map(|child| match child { - HtmlTree::Break(_) | HtmlTree::Continue(_) => quote!( #child ), - _ => match child.to_node_iterator_stream() { - Some(stream) => quote!( #acc.extend(#stream) ), - _ => quote!( #acc.push(::std::convert::Into::into(#child)) ), - }, - }); - - tokens.extend(quote!({ - #deprecations - let mut #acc = ::std::vec::Vec::<::yew::virtual_dom::VNode>::new(); - while #cond { - #(#let_stmts)* #alloc_opt; #(#body);* - } - #vlist_gen - })) + let header = quote!(while #cond); + tokens.extend(emit_loop( + header, + cond.span(), + let_stmts, + body, + deprecations, + )); } } diff --git a/packages/yew-macro/src/html_tree/mod.rs b/packages/yew-macro/src/html_tree/mod.rs index 30442aeb3cd..d839be2e522 100644 --- a/packages/yew-macro/src/html_tree/mod.rs +++ b/packages/yew-macro/src/html_tree/mod.rs @@ -16,6 +16,7 @@ mod html_for; mod html_if; mod html_iterable; mod html_list; +mod html_loop; mod html_match; mod html_node; mod html_while; diff --git a/website/docs/concepts/html/lists.mdx b/website/docs/concepts/html/lists.mdx index c06c173ffbf..a39e2d7cd6f 100644 --- a/website/docs/concepts/html/lists.mdx +++ b/website/docs/concepts/html/lists.mdx @@ -58,6 +58,10 @@ html! { }; ``` +Loop labels (`'label: for ...`, `break 'label`, `continue 'label`) are not +supported. `break` and `continue` always target the nearest enclosing `for` or +`while` in the `html!` body. + `while` and `while let` loops work the same way as `for`, producing a list of nodes from their diff --git a/website/i18n/ja/docusaurus-plugin-content-docs/current/concepts/html/lists.mdx b/website/i18n/ja/docusaurus-plugin-content-docs/current/concepts/html/lists.mdx index 8ae264590c9..c0d1dd1b85d 100644 --- a/website/i18n/ja/docusaurus-plugin-content-docs/current/concepts/html/lists.mdx +++ b/website/i18n/ja/docusaurus-plugin-content-docs/current/concepts/html/lists.mdx @@ -56,6 +56,8 @@ html! { }; ``` +ループラベル(`'label: for ...`、`break 'label`、`continue 'label`)はサポートされていません。`break` と `continue` は常に `html!` 本体内の最も内側の `for` または `while` を対象にします。 + `while` および `while let` ループは `for` と同じように動作し、本体からノードのリストを生成します。`let` バインディング、`if` ブロック、`break` / `continue` のすべてがサポートされています。 diff --git a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/concepts/html/lists.mdx b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/concepts/html/lists.mdx index 71c41c8cba9..3f7aaecfeb6 100644 --- a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/concepts/html/lists.mdx +++ b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/concepts/html/lists.mdx @@ -56,6 +56,8 @@ html! { }; ``` +不支持循环标签(`'label: for ...`、`break 'label`、`continue 'label`)。`break` 和 `continue` 总是作用于 `html!` 主体内最近的 `for` 或 `while`。 + `while` 和 `while let` 循环的工作方式与 `for` 相同,也会从循环体中生成节点列表。`let` 绑定、`if` 块以及 `break` / `continue` 都被支持。 diff --git a/website/i18n/zh-Hant/docusaurus-plugin-content-docs/current/concepts/html/lists.mdx b/website/i18n/zh-Hant/docusaurus-plugin-content-docs/current/concepts/html/lists.mdx index 8f6ea83c056..28f27ff68f0 100644 --- a/website/i18n/zh-Hant/docusaurus-plugin-content-docs/current/concepts/html/lists.mdx +++ b/website/i18n/zh-Hant/docusaurus-plugin-content-docs/current/concepts/html/lists.mdx @@ -56,6 +56,8 @@ html! { }; ``` +不支援迴圈標籤(`'label: for ...`、`break 'label`、`continue 'label`)。`break` 和 `continue` 永遠作用於 `html!` 主體內最靠近的 `for` 或 `while`。 + `while` 和 `while let` 迴圈的運作方式與 `for` 相同,也會從迴圈主體中生成節點清單。`let` 綁定、`if` 區塊以及 `break` / `continue` 都受到支援。 From f509aed38bbbc869be68f394e449f46facf57457 Mon Sep 17 00:00:00 2001 From: Matt Yan Date: Tue, 21 Apr 2026 21:34:15 +0900 Subject: [PATCH 5/8] feat(yew-macro): Any Rust statement in html! loop/if/match preamble As a consequence, labeled `break 'outer` and `continue 'outer` work for targeting loops defined in the surrounding Rust code, crossing the macro boundary. `stmts_have_divergent` detects top-level `break`/`continue`/`return` and emits `#![allow(unreachable_code)]` on an inner expression block (stable Rust rejects inner attributes directly in if-branch and match-arm positions). Docs: extended the `for`/`while` sections in lists.mdx to cover the broader statement support, labeled break/continue, match-arm break/continue, and the `::method(...)` qualified-path workaround. --- packages/yew-macro/src/html_tree/html_for.rs | 18 +- packages/yew-macro/src/html_tree/html_loop.rs | 62 ++++--- .../yew-macro/src/html_tree/html_match.rs | 62 ++++--- .../yew-macro/src/html_tree/html_while.rs | 18 +- packages/yew-macro/src/html_tree/mod.rs | 102 ++++++++++-- .../yew-macro/tests/html_macro/for-pass.rs | 155 ++++++++++++++++++ .../yew-macro/tests/html_macro/while-pass.rs | 130 +++++++++++++++ packages/yew/tests/html_for.rs | 119 ++++++++++++++ packages/yew/tests/html_while.rs | 116 +++++++++++++ website/docs/concepts/html/lists.mdx | 69 ++++++-- .../current/concepts/html/lists.mdx | 54 +++++- .../current/concepts/html/lists.mdx | 54 +++++- .../current/concepts/html/lists.mdx | 54 +++++- 13 files changed, 891 insertions(+), 122 deletions(-) diff --git a/packages/yew-macro/src/html_tree/html_for.rs b/packages/yew-macro/src/html_tree/html_for.rs index 2742c764001..92aeb8bb33c 100644 --- a/packages/yew-macro/src/html_tree/html_for.rs +++ b/packages/yew-macro/src/html_tree/html_for.rs @@ -4,7 +4,7 @@ use syn::buffer::Cursor; use syn::parse::{Parse, ParseStream}; use syn::spanned::Spanned; use syn::token::{For, In}; -use syn::{Expr, Local, Pat, braced}; +use syn::{Expr, Pat, Stmt, braced}; use super::HtmlChildrenTree; use super::html_loop::{emit_loop, parse_loop_body}; @@ -13,7 +13,7 @@ use crate::PeekValue; pub struct HtmlFor { pat: Pat, iter: Expr, - let_stmts: Vec, + stmts: Vec, body: HtmlChildrenTree, deprecations: TokenStream, } @@ -35,12 +35,12 @@ impl Parse for HtmlFor { let body_stream; braced!(body_stream in input); - let (let_stmts, body, deprecations) = parse_loop_body(&body_stream, "for")?; + let (stmts, body, deprecations) = parse_loop_body(&body_stream, "for")?; Ok(Self { pat, iter, - let_stmts, + stmts, body, deprecations, }) @@ -52,17 +52,11 @@ impl ToTokens for HtmlFor { let Self { pat, iter, - let_stmts, + stmts, body, deprecations, } = self; let header = quote!(for #pat in #iter); - tokens.extend(emit_loop( - header, - iter.span(), - let_stmts, - body, - deprecations, - )); + tokens.extend(emit_loop(header, iter.span(), stmts, body, deprecations)); } } diff --git a/packages/yew-macro/src/html_tree/html_loop.rs b/packages/yew-macro/src/html_tree/html_loop.rs index 953d6f76fce..3b5db88d42f 100644 --- a/packages/yew-macro/src/html_tree/html_loop.rs +++ b/packages/yew-macro/src/html_tree/html_loop.rs @@ -2,9 +2,11 @@ use proc_macro2::{Ident, Span, TokenStream}; use quote::quote; use syn::parse::ParseStream; use syn::spanned::Spanned; -use syn::{Expr, Local, Stmt, Token}; +use syn::{Expr, Stmt}; -use super::{HtmlChildrenTree, HtmlTree, ToNodeIterator}; +use super::{ + HtmlChildrenTree, HtmlTree, ToNodeIterator, parse_preamble_stmts, stmts_have_divergent, +}; /// Determines if an expression is guaranteed to always return the same value anywhere. pub(super) fn is_contextless_pure(expr: &Expr) -> bool { @@ -15,20 +17,13 @@ pub(super) fn is_contextless_pure(expr: &Expr) -> bool { } } -/// Parse leading `let` bindings from a loop body, then the remaining children. +/// Parse leading Rust statements from a loop body, then the remaining children. /// Also runs duplicate-key detection keyed to `loop_kind` (e.g. "for", "while"). pub(super) fn parse_loop_body( body_stream: ParseStream, loop_kind: &str, -) -> syn::Result<(Vec, HtmlChildrenTree, TokenStream)> { - let mut let_stmts = Vec::new(); - while body_stream.peek(Token![let]) { - let stmt: Stmt = body_stream.parse()?; - match stmt { - Stmt::Local(local) => let_stmts.push(local), - _ => unreachable!("peeked Token![let] but parsed non-local statement"), - } - } +) -> syn::Result<(Vec, HtmlChildrenTree, TokenStream)> { + let stmts = parse_preamble_stmts(body_stream)?; let body = HtmlChildrenTree::parse_delimited_with_nodes(body_stream)?; let deprecations = super::check_unnecessary_fragment(&body); @@ -53,7 +48,7 @@ pub(super) fn parse_loop_body( } } - Ok((let_stmts, body, deprecations)) + Ok((stmts, body, deprecations)) } /// Emit a loop that accumulates its body children into a `VList`. @@ -64,7 +59,7 @@ pub(super) fn parse_loop_body( pub(super) fn emit_loop( loop_header: TokenStream, span: Span, - let_stmts: &[Local], + stmts: &[Stmt], body: &HtmlChildrenTree, deprecations: &TokenStream, ) -> TokenStream { @@ -103,12 +98,35 @@ pub(super) fn emit_loop( }, }); - quote!({ - #deprecations - let mut #acc = ::std::vec::Vec::<::yew::virtual_dom::VNode>::new(); - #loop_header { - #(#let_stmts)* #alloc_opt; #(#body_streams);* - } - #vlist_gen - }) + let has_top_level_divergent = body + .0 + .iter() + .any(|c| matches!(c, HtmlTree::Break(_) | HtmlTree::Continue(_))) + || stmts_have_divergent(stmts); + + // Nest in an inner block when divergent, so `#![allow(unreachable_code)]` + // lands in an inner expression block (accepted everywhere) rather than in + // an if-branch or match-arm position where it would be rejected. + if has_top_level_divergent { + quote!({ + #deprecations + { + #![allow(unreachable_code)] + let mut #acc = ::std::vec::Vec::<::yew::virtual_dom::VNode>::new(); + #loop_header { + #(#stmts)* #alloc_opt; #(#body_streams);* + } + #vlist_gen + } + }) + } else { + quote!({ + #deprecations + let mut #acc = ::std::vec::Vec::<::yew::virtual_dom::VNode>::new(); + #loop_header { + #(#stmts)* #alloc_opt; #(#body_streams);* + } + #vlist_gen + }) + } } diff --git a/packages/yew-macro/src/html_tree/html_match.rs b/packages/yew-macro/src/html_tree/html_match.rs index c3bda8083c1..55736e5f8b8 100644 --- a/packages/yew-macro/src/html_tree/html_match.rs +++ b/packages/yew-macro/src/html_tree/html_match.rs @@ -3,11 +3,11 @@ use quote::{ToTokens, quote, quote_spanned}; use syn::buffer::Cursor; use syn::parse::{Parse, ParseStream}; use syn::spanned::Spanned; -use syn::{Expr, Local, Pat, Stmt, Token, braced, token}; +use syn::{Expr, Pat, Stmt, Token, braced, token}; -use super::HtmlChildrenTree; use super::html_block::html_macro_call_span; use super::html_node::HtmlNode; +use super::{HtmlChildrenTree, parse_preamble_stmts, stmts_have_divergent}; use crate::PeekValue; pub struct HtmlMatch { @@ -28,7 +28,7 @@ struct HtmlMatchArm { enum HtmlMatchArmBody { Braced { brace: token::Brace, - let_stmts: Vec, + stmts: Vec, children: HtmlChildrenTree, deprecations: TokenStream, }, @@ -107,19 +107,12 @@ impl Parse for HtmlMatchArm { let mut body = if input.cursor().group(Delimiter::Brace).is_some() { let content; let brace = braced!(content in input); - let mut let_stmts = Vec::new(); - while content.peek(Token![let]) { - let stmt: Stmt = content.parse()?; - match stmt { - Stmt::Local(local) => let_stmts.push(local), - _ => unreachable!("peeked Token![let] but parsed non-local statement"), - } - } + let stmts = parse_preamble_stmts(&content)?; let children = HtmlChildrenTree::parse_delimited_with_nodes(&content)?; let deprecations = super::check_unnecessary_fragment(&children); HtmlMatchArmBody::Braced { brace, - let_stmts, + stmts, children, deprecations, } @@ -159,29 +152,48 @@ impl ToTokens for HtmlMatchArmBody { match self { Self::Braced { brace, - let_stmts, + stmts, children, deprecations, } => { + // Match-arm body blocks reject inner attributes directly, so we + // nest in an inner expression block that accepts them. + let allow_unreachable = + stmts_have_divergent(stmts).then(|| quote!(#![allow(unreachable_code)])); tokens.extend(quote_spanned! {brace.span.span()=> { #deprecations - #(#let_stmts)* - ::yew::virtual_dom::VNode::VList(::std::rc::Rc::new( - ::yew::virtual_dom::VList::with_children( - #children, ::std::option::Option::None - ) - )) + { + #allow_unreachable + #(#stmts)* + ::yew::virtual_dom::VNode::VList(::std::rc::Rc::new( + ::yew::virtual_dom::VList::with_children( + #children, ::std::option::Option::None + ) + )) + } } }); } Self::Unbraced { tree, deprecations } => { - tokens.extend(quote_spanned! {tree.span()=> - { - #deprecations - ::std::convert::Into::<::yew::virtual_dom::VNode>::into(#tree) - } - }); + if matches!( + tree.as_ref(), + super::HtmlTree::Break(_) | super::HtmlTree::Continue(_) + ) { + tokens.extend(quote_spanned! {tree.span()=> + { + #deprecations + #tree + } + }); + } else { + tokens.extend(quote_spanned! {tree.span()=> + { + #deprecations + ::std::convert::Into::<::yew::virtual_dom::VNode>::into(#tree) + } + }); + } } } } diff --git a/packages/yew-macro/src/html_tree/html_while.rs b/packages/yew-macro/src/html_tree/html_while.rs index 7cb347e6fa7..940fe1d717f 100644 --- a/packages/yew-macro/src/html_tree/html_while.rs +++ b/packages/yew-macro/src/html_tree/html_while.rs @@ -4,7 +4,7 @@ use syn::buffer::Cursor; use syn::parse::{Parse, ParseStream}; use syn::spanned::Spanned; use syn::token::While; -use syn::{Expr, Local, braced}; +use syn::{Expr, Stmt, braced}; use super::HtmlChildrenTree; use super::html_loop::{emit_loop, parse_loop_body}; @@ -12,7 +12,7 @@ use crate::PeekValue; pub struct HtmlWhile { cond: Box, - let_stmts: Vec, + stmts: Vec, body: HtmlChildrenTree, deprecations: TokenStream, } @@ -47,11 +47,11 @@ impl Parse for HtmlWhile { let body_stream; braced!(body_stream in input); - let (let_stmts, body, deprecations) = parse_loop_body(&body_stream, "while")?; + let (stmts, body, deprecations) = parse_loop_body(&body_stream, "while")?; Ok(Self { cond, - let_stmts, + stmts, body, deprecations, }) @@ -62,17 +62,11 @@ impl ToTokens for HtmlWhile { fn to_tokens(&self, tokens: &mut TokenStream) { let Self { cond, - let_stmts, + stmts, body, deprecations, } = self; let header = quote!(while #cond); - tokens.extend(emit_loop( - header, - cond.span(), - let_stmts, - body, - deprecations, - )); + tokens.extend(emit_loop(header, cond.span(), stmts, body, deprecations)); } } diff --git a/packages/yew-macro/src/html_tree/mod.rs b/packages/yew-macro/src/html_tree/mod.rs index d839be2e522..01ae0b0d3f0 100644 --- a/packages/yew-macro/src/html_tree/mod.rs +++ b/packages/yew-macro/src/html_tree/mod.rs @@ -81,8 +81,20 @@ impl Parse for HtmlTree { HtmlType::For => Self::For(Box::new(input.parse()?)), HtmlType::While => Self::While(Box::new(input.parse()?)), HtmlType::Match => Self::Match(Box::new(input.parse()?)), - HtmlType::Break => Self::Break(input.parse()?), - HtmlType::Continue => Self::Continue(input.parse()?), + HtmlType::Break => { + let token = input.parse()?; + while input.peek(Token![;]) { + let _: Token![;] = input.parse()?; + } + Self::Break(token) + } + HtmlType::Continue => { + let token = input.parse()?; + while input.peek(Token![;]) { + let _: Token![;] = input.parse()?; + } + Self::Continue(token) + } }) } } @@ -344,8 +356,10 @@ impl HtmlChildrenTree { }, }); + let allow_unreachable = has_divergent.then(|| quote!( #![allow(unreachable_code)] )); quote! { { + #allow_unreachable let mut #vec_ident = ::std::vec::Vec::new(); #(#add_children_streams)* #vec_ident @@ -441,7 +455,12 @@ impl HtmlChildrenTree { } pub fn size_hint(&self) -> Option { - self.only_single_node_children().then_some(self.0.len()) + self.only_single_node_children().then(|| { + self.0 + .iter() + .filter(|c| !matches!(c, HtmlTree::Break(_) | HtmlTree::Continue(_))) + .count() + }) } pub fn fully_keyed(&self) -> Option { @@ -499,7 +518,7 @@ impl ToTokens for HtmlChildrenTree { pub struct HtmlRootBraced { brace: token::Brace, - let_stmts: Vec, + stmts: Vec, children: HtmlChildrenTree, deprecations: TokenStream, } @@ -515,27 +534,64 @@ impl Parse for HtmlRootBraced { let content; let brace = braced!(content in input); - let mut let_stmts = Vec::new(); - while content.peek(Token![let]) { - let stmt: syn::Stmt = content.parse()?; - match stmt { - syn::Stmt::Local(local) => let_stmts.push(local), - _ => unreachable!("peeked Token![let] but parsed non-local statement"), - } - } + let stmts = parse_preamble_stmts(&content)?; let children = HtmlChildrenTree::parse_delimited_with_nodes(&content)?; let deprecations = check_unnecessary_fragment(&children); Ok(HtmlRootBraced { brace, - let_stmts, + stmts, children, deprecations, }) } } +/// Parse leading Rust statements as a preamble: `let` bindings, items, macro +/// invocations terminated by `;`, and expression statements terminated by `;` +/// (including `break`/`continue`/`return`, with or without labels). +/// +/// Bare expressions (no trailing `;`) are left in the stream for html parsing. +/// A forked parse is used to test each candidate statement without committing +/// to it, which lets us fall through cleanly whenever the next token run is +/// not Rust-parseable (e.g. an `` or `if cond { }` that the +/// Rust expression grammar rejects). +pub(super) fn parse_preamble_stmts(input: ParseStream) -> syn::Result> { + let mut stmts = Vec::new(); + loop { + let fork = input.fork(); + let is_preamble = match fork.parse::() { + Ok(syn::Stmt::Local(_)) => true, + Ok(syn::Stmt::Item(_)) => true, + Ok(syn::Stmt::Expr(_, Some(_))) => true, + Ok(syn::Stmt::Macro(m)) => m.semi_token.is_some(), + _ => false, + }; + if !is_preamble { + break; + } + let stmt: syn::Stmt = input.parse()?; + stmts.push(stmt); + } + Ok(stmts) +} + +/// Whether any statement is a top-level divergent expression (`break`, +/// `continue`, or `return`). Callers that emit code after the statements need +/// an `#[allow(unreachable_code)]` when this is true. +pub(super) fn stmts_have_divergent(stmts: &[syn::Stmt]) -> bool { + stmts.iter().any(|stmt| { + matches!( + stmt, + syn::Stmt::Expr( + syn::Expr::Break(_) | syn::Expr::Continue(_) | syn::Expr::Return(_), + _, + ) + ) + }) +} + pub(super) fn deprecated_call(span: Span, note: &str) -> TokenStream { quote_spanned! {span=> { @@ -565,18 +621,28 @@ impl ToTokens for HtmlRootBraced { fn to_tokens(&self, tokens: &mut TokenStream) { let Self { brace, - let_stmts, + stmts, children, deprecations, } = self; + // Inner attributes are rejected on if-branch and match-arm blocks + // directly, so we always nest in an inner expression block. The inner + // attribute goes on that inner block, which Rust accepts everywhere. + let allow_unreachable = + stmts_have_divergent(stmts).then(|| quote!(#![allow(unreachable_code)])); tokens.extend(quote_spanned! {brace.span.span()=> { #deprecations - #(#let_stmts)* - ::yew::virtual_dom::VNode::VList(::std::rc::Rc::new( - ::yew::virtual_dom::VList::with_children(#children, ::std::option::Option::None) - )) + { + #allow_unreachable + #(#stmts)* + ::yew::virtual_dom::VNode::VList(::std::rc::Rc::new( + ::yew::virtual_dom::VList::with_children( + #children, ::std::option::Option::None + ) + )) + } } }); } diff --git a/packages/yew-macro/tests/html_macro/for-pass.rs b/packages/yew-macro/tests/html_macro/for-pass.rs index f82f588bdc0..2d241bc1e74 100644 --- a/packages/yew-macro/tests/html_macro/for-pass.rs +++ b/packages/yew-macro/tests/html_macro/for-pass.rs @@ -138,4 +138,159 @@ fn main() { {i} } }; + + // break with trailing semicolon + _ = ::yew::html!{ + for i in 0..10 { + if i > 5 { + break; + } + {i} + } + }; + + // continue with trailing semicolon + _ = ::yew::html!{ + for i in 0..10 { + if i % 2 == 0 { + continue; + } + {i} + } + }; + + // unbraced match arm with break + _ = ::yew::html!{ + for i in 0..10 { + match i { + 0 => break, + _ => {i}, + } + } + }; + + // unbraced match arm with continue + _ = ::yew::html!{ + for i in 0..10 { + match i { + 0 => continue, + _ => {i}, + } + } + }; + + // braced match arm with break + _ = ::yew::html!{ + for i in 0..10 { + match i { + 0 => { break }, + _ => {i}, + } + } + }; + + // break/continue in a for body must not emit `unreachable_code` warnings even + // under `#[deny(unreachable_code)]`. + #[deny(unreachable_code)] + fn break_continue_no_warn() { + _ = ::yew::html!{ + for i in 0..10 { + if i > 5 { + break; + } + if i % 2 == 0 { + continue; + } + {i} + } + }; + } + break_continue_no_warn(); + + // Expression statement in loop body preamble (`.method(..);` for side effects). + _ = ::yew::html!{ + for i in 0..5 { + let counter = ::std::cell::Cell::new(i); + counter.set(i * 2); + {counter.get()} + } + }; + + // Compound-assignment expression statement in loop body preamble. + { + let mut total: ::std::primitive::i32 = 0; + _ = ::yew::html!{ + for i in 0..5 { + let current = i; + total += current; + {current} + } + }; + _ = total; + } + + // Local fn item in loop body preamble. + _ = ::yew::html!{ + for i in 0..5 { + fn double(x: ::std::primitive::i32) -> ::std::primitive::i32 { x * 2 } + let v = double(i); + {v} + } + }; + + // Macro statement in loop body preamble (with `;`). + _ = ::yew::html!{ + for _i in 0..1 { + ::std::stringify!(debug_marker); + {"ok"} + } + }; + + // Mixed interleaving: let / expr-stmt / let / html. + { + let mut acc: ::std::primitive::i32 = 0; + _ = ::yew::html!{ + for i in 0..3 { + let x = i; + acc += x; + let y = x + 1; + {y} + } + }; + _ = acc; + } + + // Labeled `break` targeting an enclosing labeled loop in user code. + { + let mut outer_hit: ::std::primitive::i32 = 0; + 'outer: for _ in 0..3 { + _ = ::yew::html!{ + for i in 0..10 { + if i > 2 { + break 'outer; + } + {i} + } + }; + outer_hit += 1; + } + _ = outer_hit; + } + + // Labeled `continue` targeting an enclosing labeled loop in user code. + { + let mut outer_hit: ::std::primitive::i32 = 0; + 'outer: for _ in 0..3 { + _ = ::yew::html!{ + for i in 0..10 { + if i > 2 { + continue 'outer; + } + {i} + } + }; + outer_hit += 1; + } + _ = outer_hit; + } } diff --git a/packages/yew-macro/tests/html_macro/while-pass.rs b/packages/yew-macro/tests/html_macro/while-pass.rs index fa7789cb9c4..436ebd340f1 100644 --- a/packages/yew-macro/tests/html_macro/while-pass.rs +++ b/packages/yew-macro/tests/html_macro/while-pass.rs @@ -151,4 +151,134 @@ fn main() { } } }; + + // break with trailing semicolon + _ = { + let mut i: ::std::primitive::i32 = 0; + ::yew::html! { + while i < 100 { + let current = { let c = i; i += 1; c }; + if current > 5 { + break; + } + {current} + } + } + }; + + // continue with trailing semicolon + _ = { + let mut i: ::std::primitive::i32 = 0; + ::yew::html! { + while i < 10 { + let current = { let c = i; i += 1; c }; + if current % 2 == 0 { + continue; + } + {current} + } + } + }; + + // unbraced match arm with break + _ = { + let mut it = ::std::iter::IntoIterator::into_iter(0..10); + ::yew::html! { + while let ::std::option::Option::Some(v) = ::std::iter::Iterator::next(&mut it) { + match v { + 0 => break, + _ => {v}, + } + } + } + }; + + // unbraced match arm with continue + _ = { + let mut it = ::std::iter::IntoIterator::into_iter(0..10); + ::yew::html! { + while let ::std::option::Option::Some(v) = ::std::iter::Iterator::next(&mut it) { + match v { + 0 => continue, + _ => {v}, + } + } + } + }; + + // break/continue in a while body must not emit `unreachable_code` warnings even + // under `#[deny(unreachable_code)]`. + #[deny(unreachable_code)] + fn break_continue_no_warn() { + let mut i: ::std::primitive::i32 = 0; + _ = ::yew::html! { + while i < 100 { + let current = { let c = i; i += 1; c }; + if current > 5 { + break; + } + if current % 2 == 0 { + continue; + } + {current} + } + }; + } + break_continue_no_warn(); + + // Expression statement in while body: post-increment without the let-block hack. + _ = { + let mut i: ::std::primitive::i32 = 0; + ::yew::html! { + while i < 5 { + let current = i; + i += 1; + {current} + } + } + }; + + // Local fn item + expr-stmt + let, interleaved in while body preamble. + { + let mut i: ::std::primitive::i32 = 0; + let mut total: ::std::primitive::i32 = 0; + _ = ::yew::html! { + while i < 3 { + fn square(x: ::std::primitive::i32) -> ::std::primitive::i32 { x * x } + let sq = square(i); + total += sq; + i += 1; + {sq} + } + }; + _ = total; + } + + // Macro statement in while body preamble. + _ = { + let mut i: ::std::primitive::i32 = 0; + ::yew::html! { + while i < 1 { + ::std::stringify!(debug_marker); + i += 1; + {i} + } + } + }; + + // Labeled `break` targeting an enclosing labeled loop in user code. + 'outer: loop { + let mut i: ::std::primitive::i32 = 0; + _ = ::yew::html! { + while i < 100 { + let current = i; + i += 1; + if current > 2 { + break 'outer; + } + {current} + } + }; + break; + } } diff --git a/packages/yew/tests/html_for.rs b/packages/yew/tests/html_for.rs index 3feab78b2ec..30ae0fdbdda 100644 --- a/packages/yew/tests/html_for.rs +++ b/packages/yew/tests/html_for.rs @@ -84,3 +84,122 @@ async fn for_break_and_continue_together() { "12457" ); } + +#[wasm_bindgen_test] +async fn for_break_with_trailing_semi() { + #[component] + fn App() -> Html { + html! { +
+ for i in 0..10 { + if i > 2 { + break; + } + {i} + } +
+ } + } + + assert_eq!( + render_and_read::().await, + "012" + ); +} + +#[wasm_bindgen_test] +async fn for_continue_with_trailing_semi() { + #[component] + fn App() -> Html { + html! { +
+ for i in 0..6 { + if i % 2 == 0 { + continue; + } + {i} + } +
+ } + } + + assert_eq!( + render_and_read::().await, + "135" + ); +} + +#[wasm_bindgen_test] +async fn for_match_arm_break() { + #[component] + fn App() -> Html { + html! { +
+ for i in 0..10 { + match i { + 3 => break, + _ => {i}, + } + } +
+ } + } + + assert_eq!( + render_and_read::().await, + "012" + ); +} + +#[wasm_bindgen_test] +async fn for_match_arm_continue() { + #[component] + fn App() -> Html { + html! { +
+ for i in 0..6 { + match i % 2 { + 0 => continue, + _ => {i}, + } + } +
+ } + } + + assert_eq!( + render_and_read::().await, + "135" + ); +} + +#[wasm_bindgen_test] +async fn for_labeled_break_crosses_macro() { + // Demonstrates `break 'outer` inside an html! `for` body terminates the + // enclosing labeled Rust `for` loop, not just the macro's inner iteration. + // + // If labels didn't cross the macro boundary, `break 'outer` would break + // the macro's inner for and `rows_completed` would count every row. + // With working labeled break, the first inner `break 'outer` aborts the + // outer Rust `for`, so post-html! increment never runs. + #[component] + fn App() -> Html { + let mut rows_completed = 0; + 'outer: for _row in 0..3 { + let _ = html! { + for col in 0..10 { + if col >= 1 { + break 'outer; + } + {col} + } + }; + rows_completed += 1; + } + html! { +
{ format!("rows_completed={rows_completed}") }
+ } + } + + assert_eq!(render_and_read::().await, "rows_completed=0"); +} diff --git a/packages/yew/tests/html_while.rs b/packages/yew/tests/html_while.rs index 69e323a05ff..336bf52b105 100644 --- a/packages/yew/tests/html_while.rs +++ b/packages/yew/tests/html_while.rs @@ -130,3 +130,119 @@ async fn while_let_break_and_continue_together() { "12457" ); } + +#[wasm_bindgen_test] +async fn while_break_with_trailing_semi() { + #[component] + fn App() -> Html { + let mut i: i32 = 0; + html! { +
+ while i < 100 { + let current = { let c = i; i += 1; c }; + if current > 2 { + break; + } + {current} + } +
+ } + } + + assert_eq!( + render_and_read::().await, + "012" + ); +} + +#[wasm_bindgen_test] +async fn while_continue_with_trailing_semi() { + #[component] + fn App() -> Html { + let mut i: i32 = 0; + html! { +
+ while i < 6 { + let current = { let c = i; i += 1; c }; + if current % 2 == 0 { + continue; + } + {current} + } +
+ } + } + + assert_eq!( + render_and_read::().await, + "135" + ); +} + +#[wasm_bindgen_test] +async fn while_let_match_arm_break() { + #[component] + fn App() -> Html { + let mut it = (0..10).into_iter(); + html! { +
+ while let Some(v) = it.next() { + match v { + 3 => break, + _ => {v}, + } + } +
+ } + } + + assert_eq!( + render_and_read::().await, + "012" + ); +} + +#[wasm_bindgen_test] +async fn while_let_match_arm_continue() { + #[component] + fn App() -> Html { + let mut it = (0..6).into_iter(); + html! { +
+ while let Some(v) = it.next() { + match v % 2 { + 0 => continue, + _ => {v}, + } + } +
+ } + } + + assert_eq!( + render_and_read::().await, + "135" + ); +} + +#[wasm_bindgen_test] +async fn while_expr_stmt_preamble_increments() { + #[component] + fn App() -> Html { + let mut i: i32 = 0; + html! { +
+ while i < 4 { + let current = i; + i += 1; + {current} + } +
+ } + } + + assert_eq!( + render_and_read::().await, + "0123" + ); +} diff --git a/website/docs/concepts/html/lists.mdx b/website/docs/concepts/html/lists.mdx index a39e2d7cd6f..ebdcb8556ec 100644 --- a/website/docs/concepts/html/lists.mdx +++ b/website/docs/concepts/html/lists.mdx @@ -25,7 +25,10 @@ html! { }; ``` -`for` loop bodies also support `let` bindings before the html children: +`for` loop bodies accept Rust statements before the html children. Any +terminated statement works: `let` bindings, expression statements ending in +`;`, item definitions (`fn`, `struct`, `use`, ...), and macro invocations with +`;`. ```rust , ignore use yew::prelude::*; @@ -58,17 +61,60 @@ html! { }; ``` -Loop labels (`'label: for ...`, `break 'label`, `continue 'label`) are not -supported. `break` and `continue` always target the nearest enclosing `for` or -`while` in the `html!` body. +`break` and `continue` are also available directly as match arms, whether +braced or unbraced: + +```rust +use yew::prelude::*; + +html! { + for i in 0..10 { + match i { + 0 => continue, + 8.. => break, + _ => {i}, + } + } +}; +``` + +Loop labels are supported. `break 'label` and `continue 'label` target a +labeled loop defined in the surrounding Rust code, letting you exit an +enclosing loop from inside the macro: + +```rust , ignore +use yew::prelude::*; + +let mut rendered = Vec::new(); +'outer: for section in sections { + rendered.push(html! { + for item in section.items { + if should_stop(&item) { + break 'outer; + } + {item.name} + } + }); +} +``` + +:::note Qualified paths + +A bare qualified-path expression like `::method(...)` collides with the +`` element open tag and is rejected. Two ways out: + +- Add a trailing `;` and it becomes an expression statement in the preamble: + `::method(...);`. The return value is discarded. +- Wrap it in `{...}` to use the return value as a node: + `{ ::method(...) }`. + +:::
`while` and `while let` loops work the same way as `for`, producing a list of nodes from their -body. `let` bindings, `if` blocks, and `break` / `continue` are all supported. - -Pair `while let` with an iterator for a clean iteration pattern, where `break` and `continue` -compose naturally: +body. All the statement forms accepted by `for` bodies (let bindings, expression statements, +items, macros) are also accepted here, along with `if` blocks and `break`/`continue`. ```rust use yew::prelude::*; @@ -88,9 +134,7 @@ html! { }; ``` -A plain `while` with a condition works too. Any state the loop needs to advance its condition -must be set up via the outer scope and updated inside the body, typically via a `let` binding -at the top of the body: +A plain `while` with a condition works too: ```rust use yew::prelude::*; @@ -99,7 +143,8 @@ let mut counter: i32 = 0; html! { while counter < 5 { - let current = { let c = counter; counter += 1; c }; + let current = counter; + counter += 1; {current} } }; diff --git a/website/i18n/ja/docusaurus-plugin-content-docs/current/concepts/html/lists.mdx b/website/i18n/ja/docusaurus-plugin-content-docs/current/concepts/html/lists.mdx index c0d1dd1b85d..fae271b25c4 100644 --- a/website/i18n/ja/docusaurus-plugin-content-docs/current/concepts/html/lists.mdx +++ b/website/i18n/ja/docusaurus-plugin-content-docs/current/concepts/html/lists.mdx @@ -23,7 +23,7 @@ html! { }; ``` -`for` ループ本体では、html 子要素の前に `let` バインディングも使用できます: +`for` ループ本体では、html 子要素の前に Rust の文を置くことができます。終端子を持つ文であれば何でも動作します:`let` バインディング、`;` で終わる式文、アイテム定義(`fn`、`struct`、`use` など)、`;` 付きのマクロ呼び出しです。 ```rust , ignore use yew::prelude::*; @@ -56,13 +56,52 @@ html! { }; ``` -ループラベル(`'label: for ...`、`break 'label`、`continue 'label`)はサポートされていません。`break` と `continue` は常に `html!` 本体内の最も内側の `for` または `while` を対象にします。 +`break` と `continue` は、波括弧付きでも波括弧なしでも、match アームの本体としてそのまま使えます: + +```rust +use yew::prelude::*; + +html! { + for i in 0..10 { + match i { + 0 => continue, + 8.. => break, + _ => {i}, + } + } +}; +``` + +ループラベルもサポートされています。`break 'label` や `continue 'label` は、マクロを囲む Rust コードで定義されたラベル付きループを対象にでき、マクロの内側から外側のループを抜けることができます: + +```rust , ignore +use yew::prelude::*; + +let mut rendered = Vec::new(); +'outer: for section in sections { + rendered.push(html! { + for item in section.items { + if should_stop(&item) { + break 'outer; + } + {item.name} + } + }); +} +``` + +:::note 限定パス + +`::method(...)` のような裸の限定パス式は `` という要素開始タグと衝突し、受け付けられません。回避方法は 2 つあります: + +- 末尾に `;` を付けてプリアンブルの式文にします:`::method(...);`。戻り値は破棄されます。 +- `{...}` で囲んで戻り値をノードとして使います:`{ ::method(...) }`。 + +::: -`while` および `while let` ループは `for` と同じように動作し、本体からノードのリストを生成します。`let` バインディング、`if` ブロック、`break` / `continue` のすべてがサポートされています。 - -`while let` をイテレータと組み合わせて使うと、きれいな反復パターンになり、`break` と `continue` が自然に組み合わせられます: +`while` および `while let` ループは `for` と同じように動作し、本体からノードのリストを生成します。`for` 本体で受け付けられるすべての文の形式(let バインディング、式文、アイテム、マクロ)がここでも受け付けられ、`if` ブロックと `break` / `continue` も使えます。 ```rust use yew::prelude::*; @@ -82,7 +121,7 @@ html! { }; ``` -条件付きの通常の `while` も使用できます。ループが条件を進めるために必要な状態はすべて外側のスコープで用意し、本体の中で更新する必要があります。通常は本体の先頭の `let` バインディングで行います: +条件付きの通常の `while` も使用できます: ```rust use yew::prelude::*; @@ -91,7 +130,8 @@ let mut counter: i32 = 0; html! { while counter < 5 { - let current = { let c = counter; counter += 1; c }; + let current = counter; + counter += 1; {current} } }; diff --git a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/concepts/html/lists.mdx b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/concepts/html/lists.mdx index 3f7aaecfeb6..35a17177329 100644 --- a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/concepts/html/lists.mdx +++ b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/concepts/html/lists.mdx @@ -23,7 +23,7 @@ html! { }; ``` -`for` 循环体也支持在 html 子节点前使用 `let` 绑定: +`for` 循环体支持在 html 子节点前书写 Rust 语句。任何带终止符的语句都可以:`let` 绑定、以 `;` 结尾的表达式语句、项定义(`fn`、`struct`、`use` 等)以及带 `;` 的宏调用。 ```rust , ignore use yew::prelude::*; @@ -56,13 +56,52 @@ html! { }; ``` -不支持循环标签(`'label: for ...`、`break 'label`、`continue 'label`)。`break` 和 `continue` 总是作用于 `html!` 主体内最近的 `for` 或 `while`。 +`break` 和 `continue` 也可以直接作为 match 分支使用,无论带花括号还是不带: + +```rust +use yew::prelude::*; + +html! { + for i in 0..10 { + match i { + 0 => continue, + 8.. => break, + _ => {i}, + } + } +}; +``` + +循环标签也受支持。`break 'label` 和 `continue 'label` 可以作用于宏外部 Rust 代码中定义的带标签的循环,让你能从宏内部退出外层循环: + +```rust , ignore +use yew::prelude::*; + +let mut rendered = Vec::new(); +'outer: for section in sections { + rendered.push(html! { + for item in section.items { + if should_stop(&item) { + break 'outer; + } + {item.name} + } + }); +} +``` + +:::note 限定路径 + +像 `::method(...)` 这样的裸限定路径表达式会与 `` 元素起始标签冲突,因此会被拒绝。有两种解决方法: + +- 在末尾加上 `;`,使其成为前置的表达式语句:`::method(...);`。返回值会被丢弃。 +- 用 `{...}` 包起来,将返回值作为节点使用:`{ ::method(...) }`。 + +::: -`while` 和 `while let` 循环的工作方式与 `for` 相同,也会从循环体中生成节点列表。`let` 绑定、`if` 块以及 `break` / `continue` 都被支持。 - -将 `while let` 与迭代器搭配使用可以得到一种清晰的迭代模式,其中 `break` 和 `continue` 可以自然地组合使用: +`while` 和 `while let` 循环的工作方式与 `for` 相同,也会从循环体中生成节点列表。`for` 循环体中支持的所有语句形式(let 绑定、表达式语句、项、宏)在这里同样可用,外加 `if` 块和 `break` / `continue`。 ```rust use yew::prelude::*; @@ -82,7 +121,7 @@ html! { }; ``` -带条件的普通 `while` 也可以使用。循环推进条件所需的任何状态都必须在外部作用域中设置,并在循环体内部更新,通常通过在循环体顶部添加 `let` 绑定来完成: +带条件的普通 `while` 也可以使用: ```rust use yew::prelude::*; @@ -91,7 +130,8 @@ let mut counter: i32 = 0; html! { while counter < 5 { - let current = { let c = counter; counter += 1; c }; + let current = counter; + counter += 1; {current} } }; diff --git a/website/i18n/zh-Hant/docusaurus-plugin-content-docs/current/concepts/html/lists.mdx b/website/i18n/zh-Hant/docusaurus-plugin-content-docs/current/concepts/html/lists.mdx index 28f27ff68f0..8bde1886279 100644 --- a/website/i18n/zh-Hant/docusaurus-plugin-content-docs/current/concepts/html/lists.mdx +++ b/website/i18n/zh-Hant/docusaurus-plugin-content-docs/current/concepts/html/lists.mdx @@ -23,7 +23,7 @@ html! { }; ``` -`for` 迴圈主體也支援在 html 子節點前使用 `let` 綁定: +`for` 迴圈主體支援在 html 子節點前書寫 Rust 陳述式。任何帶終止符號的陳述式都可以:`let` 綁定、以 `;` 結尾的運算式陳述式、項目定義(`fn`、`struct`、`use` 等)以及帶 `;` 的巨集呼叫。 ```rust , ignore use yew::prelude::*; @@ -56,13 +56,52 @@ html! { }; ``` -不支援迴圈標籤(`'label: for ...`、`break 'label`、`continue 'label`)。`break` 和 `continue` 永遠作用於 `html!` 主體內最靠近的 `for` 或 `while`。 +`break` 和 `continue` 也可以直接作為 match 分支使用,無論帶大括號或不帶: + +```rust +use yew::prelude::*; + +html! { + for i in 0..10 { + match i { + 0 => continue, + 8.. => break, + _ => {i}, + } + } +}; +``` + +迴圈標籤也受支援。`break 'label` 和 `continue 'label` 可以作用於巨集外部 Rust 程式碼中定義的帶標籤迴圈,讓你能從巨集內部跳出外層迴圈: + +```rust , ignore +use yew::prelude::*; + +let mut rendered = Vec::new(); +'outer: for section in sections { + rendered.push(html! { + for item in section.items { + if should_stop(&item) { + break 'outer; + } + {item.name} + } + }); +} +``` + +:::note 限定路徑 + +像 `::method(...)` 這樣的裸限定路徑運算式會與 `` 元素起始標籤衝突,因此會被拒絕。有兩種解決方式: + +- 在結尾加上 `;`,讓它成為前置的運算式陳述式:`::method(...);`。傳回值會被丟棄。 +- 用 `{...}` 包起來,將傳回值當作節點使用:`{ ::method(...) }`。 + +::: -`while` 和 `while let` 迴圈的運作方式與 `for` 相同,也會從迴圈主體中生成節點清單。`let` 綁定、`if` 區塊以及 `break` / `continue` 都受到支援。 - -將 `while let` 與迭代器搭配使用,可以得到一種清晰的迭代模式,其中 `break` 和 `continue` 可以自然地組合使用: +`while` 和 `while let` 迴圈的運作方式與 `for` 相同,也會從迴圈主體中生成節點清單。`for` 迴圈主體中支援的所有陳述式形式(let 綁定、運算式陳述式、項目、巨集)在這裡同樣可用,此外還有 `if` 區塊以及 `break` / `continue`。 ```rust use yew::prelude::*; @@ -82,7 +121,7 @@ html! { }; ``` -帶條件的一般 `while` 也可以使用。迴圈推進條件所需的任何狀態都必須在外部作用域中設定,並在迴圈主體內部更新,通常是透過在迴圈主體頂部加入 `let` 綁定來完成: +帶條件的一般 `while` 也可以使用: ```rust use yew::prelude::*; @@ -91,7 +130,8 @@ let mut counter: i32 = 0; html! { while counter < 5 { - let current = { let c = counter; counter += 1; c }; + let current = counter; + counter += 1; {current} } }; From 5a18a7211d67bea709f4bd7fb2cd029020ddbcb9 Mon Sep 17 00:00:00 2001 From: Matt Yan Date: Wed, 22 Apr 2026 10:38:06 +0900 Subject: [PATCH 6/8] feat(yew-macro): labeled break/continue without `;` and bare return at body top --- packages/yew-macro/src/html_tree/html_loop.rs | 13 +- .../yew-macro/src/html_tree/html_match.rs | 4 +- packages/yew-macro/src/html_tree/mod.rs | 115 ++++++++++++++++-- .../yew-macro/tests/html_macro/for-pass.rs | 97 +++++++++++++++ .../yew-macro/tests/html_macro/while-pass.rs | 104 ++++++++++++++++ packages/yew/tests/html_for.rs | 92 ++++++++++++++ packages/yew/tests/html_while.rs | 94 ++++++++++++++ 7 files changed, 499 insertions(+), 20 deletions(-) diff --git a/packages/yew-macro/src/html_tree/html_loop.rs b/packages/yew-macro/src/html_tree/html_loop.rs index 3b5db88d42f..b3a201272b8 100644 --- a/packages/yew-macro/src/html_tree/html_loop.rs +++ b/packages/yew-macro/src/html_tree/html_loop.rs @@ -91,18 +91,19 @@ pub(super) fn emit_loop( }; let body_streams = body.0.iter().map(|child| match child { - HtmlTree::Break(_) | HtmlTree::Continue(_) => quote!( #child ), + HtmlTree::Break(_) | HtmlTree::Continue(_) | HtmlTree::Return(_) => quote!( #child ), _ => match child.to_node_iterator_stream() { Some(stream) => quote!( #acc.extend(#stream) ), _ => quote!( #acc.push(::std::convert::Into::into(#child)) ), }, }); - let has_top_level_divergent = body - .0 - .iter() - .any(|c| matches!(c, HtmlTree::Break(_) | HtmlTree::Continue(_))) - || stmts_have_divergent(stmts); + let has_top_level_divergent = body.0.iter().any(|c| { + matches!( + c, + HtmlTree::Break(_) | HtmlTree::Continue(_) | HtmlTree::Return(_) + ) + }) || stmts_have_divergent(stmts); // Nest in an inner block when divergent, so `#![allow(unreachable_code)]` // lands in an inner expression block (accepted everywhere) rather than in diff --git a/packages/yew-macro/src/html_tree/html_match.rs b/packages/yew-macro/src/html_tree/html_match.rs index 55736e5f8b8..5ee53a88825 100644 --- a/packages/yew-macro/src/html_tree/html_match.rs +++ b/packages/yew-macro/src/html_tree/html_match.rs @@ -178,7 +178,9 @@ impl ToTokens for HtmlMatchArmBody { Self::Unbraced { tree, deprecations } => { if matches!( tree.as_ref(), - super::HtmlTree::Break(_) | super::HtmlTree::Continue(_) + super::HtmlTree::Break(_) + | super::HtmlTree::Continue(_) + | super::HtmlTree::Return(_) ) { tokens.extend(quote_spanned! {tree.span()=> { diff --git a/packages/yew-macro/src/html_tree/mod.rs b/packages/yew-macro/src/html_tree/mod.rs index 01ae0b0d3f0..fc1a0d5faf1 100644 --- a/packages/yew-macro/src/html_tree/mod.rs +++ b/packages/yew-macro/src/html_tree/mod.rs @@ -2,6 +2,7 @@ use proc_macro2::{Delimiter, Ident, Span, TokenStream}; use quote::{ToTokens, quote, quote_spanned}; use syn::buffer::Cursor; use syn::ext::IdentExt; +use syn::parse::discouraged::Speculative; use syn::parse::{Parse, ParseStream}; use syn::spanned::Spanned; use syn::{Token, braced, token}; @@ -49,6 +50,7 @@ pub enum HtmlType { Match, Break, Continue, + Return, Empty, } @@ -62,8 +64,9 @@ pub enum HtmlTree { While(Box), Match(Box), Node(Box), - Break(Token![break]), - Continue(Token![continue]), + Break(Box), + Continue(Box), + Return(Box), Empty, } @@ -82,18 +85,25 @@ impl Parse for HtmlTree { HtmlType::While => Self::While(Box::new(input.parse()?)), HtmlType::Match => Self::Match(Box::new(input.parse()?)), HtmlType::Break => { - let token = input.parse()?; + let expr = parse_break(input)?; while input.peek(Token![;]) { let _: Token![;] = input.parse()?; } - Self::Break(token) + Self::Break(Box::new(expr)) } HtmlType::Continue => { - let token = input.parse()?; + let expr = parse_continue(input)?; while input.peek(Token![;]) { let _: Token![;] = input.parse()?; } - Self::Continue(token) + Self::Continue(Box::new(expr)) + } + HtmlType::Return => { + let expr = parse_return(input)?; + while input.peek(Token![;]) { + let _: Token![;] = input.parse()?; + } + Self::Return(Box::new(expr)) } }) } @@ -138,6 +148,8 @@ impl HtmlTree { .unwrap_or(false) { Some(HtmlType::Continue) + } else if cursor.ident().map(|(i, _)| i == "return").unwrap_or(false) { + Some(HtmlType::Return) } else if input.peek(Token![<]) { let _lt: Token![<] = input.parse().ok()?; @@ -189,8 +201,9 @@ impl ToTokens for HtmlTree { Self::While(block) => block.to_tokens(tokens), Self::Match(block) => block.to_tokens(tokens), Self::Node(node) => node.to_tokens(tokens), - Self::Break(token) => token.to_tokens(tokens), - Self::Continue(token) => token.to_tokens(tokens), + Self::Break(expr) => expr.to_tokens(tokens), + Self::Continue(expr) => expr.to_tokens(tokens), + Self::Return(expr) => expr.to_tokens(tokens), } } } @@ -324,9 +337,12 @@ impl HtmlChildrenTree { pub fn to_build_vec_token_stream(&self) -> TokenStream { let Self(children) = self; - let has_divergent = children - .iter() - .any(|c| matches!(c, HtmlTree::Break(_) | HtmlTree::Continue(_))); + let has_divergent = children.iter().any(|c| { + matches!( + c, + HtmlTree::Break(_) | HtmlTree::Continue(_) | HtmlTree::Return(_) + ) + }); if !has_divergent && self.only_single_node_children() { // optimize for the common case where all children are single nodes (only using literal @@ -341,7 +357,9 @@ impl HtmlChildrenTree { let vec_ident = Ident::new("__yew_v", Span::mixed_site()); let add_children_streams = children.iter().map(|child| match child { - HtmlTree::Break(_) | HtmlTree::Continue(_) => quote!( #child; ), + HtmlTree::Break(_) | HtmlTree::Continue(_) | HtmlTree::Return(_) => { + quote!( #child; ) + } _ => match child.to_node_iterator_stream() { Some(node_iterator_stream) => { quote! { @@ -458,7 +476,12 @@ impl HtmlChildrenTree { self.only_single_node_children().then(|| { self.0 .iter() - .filter(|c| !matches!(c, HtmlTree::Break(_) | HtmlTree::Continue(_))) + .filter(|c| { + !matches!( + c, + HtmlTree::Break(_) | HtmlTree::Continue(_) | HtmlTree::Return(_) + ) + }) .count() }) } @@ -501,6 +524,7 @@ impl HtmlChildrenTree { | HtmlTree::Match(_) | HtmlTree::Break(_) | HtmlTree::Continue(_) + | HtmlTree::Return(_) | HtmlTree::Empty => { return Some(false); } @@ -577,6 +601,71 @@ pub(super) fn parse_preamble_stmts(input: ParseStream) -> syn::Result syn::Result { + let fork = input.fork(); + if let Ok(expr) = fork.parse::() { + input.advance_to(&fork); + return Ok(expr); + } + let break_token: Token![break] = input.parse()?; + let label: Option = if input.peek(syn::Lifetime) { + Some(input.parse()?) + } else { + None + }; + Ok(syn::ExprBreak { + attrs: Vec::new(), + break_token, + label, + expr: None, + }) +} + +/// Parse `continue [label]` with the same fork-and-fallback strategy as +/// [`parse_break`]. +pub(super) fn parse_continue(input: syn::parse::ParseStream) -> syn::Result { + let fork = input.fork(); + if let Ok(expr) = fork.parse::() { + input.advance_to(&fork); + return Ok(expr); + } + let continue_token: Token![continue] = input.parse()?; + let label: Option = if input.peek(syn::Lifetime) { + Some(input.parse()?) + } else { + None + }; + Ok(syn::ExprContinue { + attrs: Vec::new(), + continue_token, + label, + }) +} + +/// Parse `return [value]` with the same fork-and-fallback strategy as +/// [`parse_break`]. The fallback emits a bare `return` (no value), leaving +/// anything that follows — notably `` or other html — for the children +/// parser. +pub(super) fn parse_return(input: syn::parse::ParseStream) -> syn::Result { + let fork = input.fork(); + if let Ok(expr) = fork.parse::() { + input.advance_to(&fork); + return Ok(expr); + } + let return_token: Token![return] = input.parse()?; + Ok(syn::ExprReturn { + attrs: Vec::new(), + return_token, + expr: None, + }) +} + /// Whether any statement is a top-level divergent expression (`break`, /// `continue`, or `return`). Callers that emit code after the statements need /// an `#[allow(unreachable_code)]` when this is true. diff --git a/packages/yew-macro/tests/html_macro/for-pass.rs b/packages/yew-macro/tests/html_macro/for-pass.rs index 2d241bc1e74..4747a2cd8f0 100644 --- a/packages/yew-macro/tests/html_macro/for-pass.rs +++ b/packages/yew-macro/tests/html_macro/for-pass.rs @@ -293,4 +293,101 @@ fn main() { } _ = outer_hit; } + + // Labeled `break 'outer` without a trailing `;` (body-top position). + { + let mut outer_hit: ::std::primitive::i32 = 0; + 'outer: for _ in 0..3 { + _ = ::yew::html!{ + for i in 0..10 { + if i > 2 { + break 'outer + } + {i} + } + }; + outer_hit += 1; + } + _ = outer_hit; + } + + // Labeled `continue 'outer` without a trailing `;` (body-top position). + { + let mut outer_hit: ::std::primitive::i32 = 0; + 'outer: for _ in 0..3 { + _ = ::yew::html!{ + for i in 0..10 { + if i > 2 { + continue 'outer + } + {i} + } + }; + outer_hit += 1; + } + _ = outer_hit; + } + + // Bare `return` (no value, no trailing `;`) at body top. Returns `()` from main. + fn bare_return_at_body_top() { + _ = ::yew::html!{ + for _ in 0..1 { + return + {"unreachable"} + } + }; + } + bare_return_at_body_top(); + + // Bare `return` in unbraced match arm. + fn return_in_unbraced_match_arm() { + _ = ::yew::html!{ + for i in 0..10 { + match i { + 3 => return, + _ => {i}, + } + } + }; + } + return_in_unbraced_match_arm(); + + // Bare `return` in braced match arm. + fn return_in_braced_match_arm() { + _ = ::yew::html!{ + for i in 0..10 { + match i { + 3 => { return }, + _ => {i}, + } + } + }; + } + return_in_braced_match_arm(); + + // `return` with a value in preamble position (statement form with `;`). + fn return_value_from_preamble() -> ::std::primitive::i32 { + _ = ::yew::html!{ + for i in 0..3 { + return i; + {i} + } + }; + 0 + } + let _ = return_value_from_preamble(); + + // `return` with a value in unbraced match arm (value part of ExprReturn). + fn return_value_from_unbraced_arm() -> ::std::primitive::i32 { + _ = ::yew::html!{ + for i in 0..10 { + match i { + 3 => return i, + _ => {i}, + } + } + }; + 0 + } + let _ = return_value_from_unbraced_arm(); } diff --git a/packages/yew-macro/tests/html_macro/while-pass.rs b/packages/yew-macro/tests/html_macro/while-pass.rs index 436ebd340f1..11e010a404f 100644 --- a/packages/yew-macro/tests/html_macro/while-pass.rs +++ b/packages/yew-macro/tests/html_macro/while-pass.rs @@ -281,4 +281,108 @@ fn main() { }; break; } + + // Labeled `break 'outer` without a trailing `;` (body-top position). + 'outer: loop { + let mut i: ::std::primitive::i32 = 0; + _ = ::yew::html! { + while i < 100 { + let current = i; + i += 1; + if current > 2 { + break 'outer + } + {current} + } + }; + break; + } + + // Labeled `continue 'outer` without a trailing `;` inside a while body. + { + let mut outer_hit: ::std::primitive::i32 = 0; + 'outer: for _ in 0..3 { + let mut i: ::std::primitive::i32 = 0; + _ = ::yew::html! { + while i < 10 { + let current = i; + i += 1; + if current > 2 { + continue 'outer + } + {current} + } + }; + outer_hit += 1; + } + _ = outer_hit; + } + + // Bare `return` (no value, no `;`) at body top. + fn bare_return_at_body_top() { + let mut it = ::std::iter::IntoIterator::into_iter(0..1); + _ = ::yew::html! { + while let ::std::option::Option::Some(_) = ::std::iter::Iterator::next(&mut it) { + return + {"unreachable"} + } + }; + } + bare_return_at_body_top(); + + // Bare `return` in unbraced match arm (inside while-let). + fn return_in_unbraced_match_arm() { + let mut it = ::std::iter::IntoIterator::into_iter(0..10); + _ = ::yew::html! { + while let ::std::option::Option::Some(v) = ::std::iter::Iterator::next(&mut it) { + match v { + 3 => return, + _ => {v}, + } + } + }; + } + return_in_unbraced_match_arm(); + + // Bare `return` in braced match arm. + fn return_in_braced_match_arm() { + let mut it = ::std::iter::IntoIterator::into_iter(0..10); + _ = ::yew::html! { + while let ::std::option::Option::Some(v) = ::std::iter::Iterator::next(&mut it) { + match v { + 3 => { return }, + _ => {v}, + } + } + }; + } + return_in_braced_match_arm(); + + // `return` with a value in preamble position. + fn return_value_from_preamble() -> ::std::primitive::i32 { + let mut it = ::std::iter::IntoIterator::into_iter(0..3); + _ = ::yew::html! { + while let ::std::option::Option::Some(current) = ::std::iter::Iterator::next(&mut it) { + return current; + {current} + } + }; + 0 + } + let _ = return_value_from_preamble(); + + // `return` with a value in unbraced match arm. + fn return_value_from_unbraced_arm() -> ::std::primitive::i32 { + let mut it = ::std::iter::IntoIterator::into_iter(0..10); + _ = ::yew::html! { + while let ::std::option::Option::Some(v) = ::std::iter::Iterator::next(&mut it) { + match v { + 3 => return v, + _ => {v}, + } + } + }; + 0 + } + let _ = return_value_from_unbraced_arm(); } diff --git a/packages/yew/tests/html_for.rs b/packages/yew/tests/html_for.rs index 30ae0fdbdda..c2d7afdef55 100644 --- a/packages/yew/tests/html_for.rs +++ b/packages/yew/tests/html_for.rs @@ -203,3 +203,95 @@ async fn for_labeled_break_crosses_macro() { assert_eq!(render_and_read::().await, "rows_completed=0"); } + +#[wasm_bindgen_test] +async fn for_labeled_break_no_semi() { + #[component] + fn App() -> Html { + let mut rows_completed = 0; + 'outer: for _row in 0..3 { + let _ = html! { + for col in 0..10 { + if col >= 1 { + break 'outer + } + {col} + } + }; + rows_completed += 1; + } + html! { +
{ format!("rows_completed={rows_completed}") }
+ } + } + + assert_eq!(render_and_read::().await, "rows_completed=0"); +} + +#[wasm_bindgen_test] +async fn for_labeled_continue_no_semi() { + #[component] + fn App() -> Html { + let mut rows_skipped = 0; + 'outer: for row in 0..3 { + let _ = html! { + for col in 0..10 { + if row == 1 { + continue 'outer + } + {col} + } + }; + if row == 1 { + rows_skipped += 1; + } + } + html! { +
{ format!("rows_skipped={rows_skipped}") }
+ } + } + + // Row 1's inner `continue 'outer` skips over `rows_skipped += 1;`, so + // the counter stays at 0 — proving the label crossed the macro boundary. + assert_eq!(render_and_read::().await, "rows_skipped=0"); +} + +#[wasm_bindgen_test] +async fn for_return_exits_component() { + // `return` inside an html! body returns from the enclosing function. The + // component function therefore yields the inner `html! {

}` + // instead of the outer `

` fragment, and the "never" span is dead + // code. Verifies bare `return val;` semantics in a for-body preamble. + #[component] + fn App() -> Html { + html! { +
+ for _ in 0..1 { + return html!{

{"returned"}

}; + {"never"} + } +
+ } + } + + assert_eq!(render_and_read::().await, "returned"); +} + +#[wasm_bindgen_test] +async fn for_return_in_unbraced_match_arm() { + #[component] + fn App() -> Html { + html! { +
+ for i in 0..10 { + match i { + 3 => return html!{

{format!("stopped at {i}")}

}, + _ => {i}, + } + } +
+ } + } + + assert_eq!(render_and_read::().await, "stopped at 3"); +} diff --git a/packages/yew/tests/html_while.rs b/packages/yew/tests/html_while.rs index 336bf52b105..ff3ded15b61 100644 --- a/packages/yew/tests/html_while.rs +++ b/packages/yew/tests/html_while.rs @@ -246,3 +246,97 @@ async fn while_expr_stmt_preamble_increments() { "0123" ); } + +#[wasm_bindgen_test] +async fn while_labeled_break_no_semi() { + #[component] + fn App() -> Html { + let mut rows_completed = 0; + 'outer: for _row in 0..3 { + let mut i: i32 = 0; + let _ = html! { + while i < 10 { + let current = i; + i += 1; + if current >= 1 { + break 'outer + } + {current} + } + }; + rows_completed += 1; + } + html! { +
{ format!("rows_completed={rows_completed}") }
+ } + } + + assert_eq!(render_and_read::().await, "rows_completed=0"); +} + +#[wasm_bindgen_test] +async fn while_labeled_continue_no_semi() { + #[component] + fn App() -> Html { + let mut rows_skipped = 0; + 'outer: for row in 0..3 { + let mut i: i32 = 0; + let _ = html! { + while i < 2 { + let current = i; + i += 1; + if row == 1 { + continue 'outer + } + {current} + } + }; + if row == 1 { + rows_skipped += 1; + } + } + html! { +
{ format!("rows_skipped={rows_skipped}") }
+ } + } + + assert_eq!(render_and_read::().await, "rows_skipped=0"); +} + +#[wasm_bindgen_test] +async fn while_return_exits_component() { + #[component] + fn App() -> Html { + let mut it = (0..1).into_iter(); + html! { +
+ while let Some(_) = it.next() { + return html!{

{"returned"}

}; + {"never"} + } +
+ } + } + + assert_eq!(render_and_read::().await, "returned"); +} + +#[wasm_bindgen_test] +async fn while_return_in_unbraced_match_arm() { + #[component] + fn App() -> Html { + let mut it = (0..10).into_iter(); + html! { +
+ while let Some(v) = it.next() { + match v { + 3 => return html!{

{format!("stopped at {v}")}

}, + _ => {v}, + } + } +
+ } + } + + assert_eq!(render_and_read::().await, "stopped at 3"); +} From eb71e517a4f005644e8f2658bc5a0993187062a0 Mon Sep 17 00:00:00 2001 From: Matt Yan Date: Wed, 22 Apr 2026 11:57:11 +0900 Subject: [PATCH 7/8] fix: clearer error for `break`/`continue`/`return` with HTML in match arm and `__yew_v` hygiene --- packages/yew-macro/src/html_tree/html_for.rs | 3 +- packages/yew-macro/src/html_tree/html_loop.rs | 6 +-- .../yew-macro/src/html_tree/html_match.rs | 23 +++++++++ .../yew-macro/src/html_tree/html_while.rs | 2 +- .../tests/html_macro/html-match-fail.rs | 40 ++++++++++++++++ .../tests/html_macro/html-match-fail.stderr | 24 ++++++++++ packages/yew/tests/html_for.rs | 48 +++++++++++++++++++ packages/yew/tests/html_while.rs | 22 +++++++++ 8 files changed, 161 insertions(+), 7 deletions(-) diff --git a/packages/yew-macro/src/html_tree/html_for.rs b/packages/yew-macro/src/html_tree/html_for.rs index 92aeb8bb33c..7979e133795 100644 --- a/packages/yew-macro/src/html_tree/html_for.rs +++ b/packages/yew-macro/src/html_tree/html_for.rs @@ -2,7 +2,6 @@ use proc_macro2::TokenStream; use quote::{ToTokens, quote}; use syn::buffer::Cursor; use syn::parse::{Parse, ParseStream}; -use syn::spanned::Spanned; use syn::token::{For, In}; use syn::{Expr, Pat, Stmt, braced}; @@ -57,6 +56,6 @@ impl ToTokens for HtmlFor { deprecations, } = self; let header = quote!(for #pat in #iter); - tokens.extend(emit_loop(header, iter.span(), stmts, body, deprecations)); + tokens.extend(emit_loop(header, stmts, body, deprecations)); } } diff --git a/packages/yew-macro/src/html_tree/html_loop.rs b/packages/yew-macro/src/html_tree/html_loop.rs index b3a201272b8..b4a925e1904 100644 --- a/packages/yew-macro/src/html_tree/html_loop.rs +++ b/packages/yew-macro/src/html_tree/html_loop.rs @@ -54,16 +54,14 @@ pub(super) fn parse_loop_body( /// Emit a loop that accumulates its body children into a `VList`. /// /// `loop_header` is the native Rust loop syntax without its body, e.g. -/// `for #pat in #iter` or `while #cond`. `span` is used to place the internal -/// accumulator identifier. +/// `for #pat in #iter` or `while #cond`. pub(super) fn emit_loop( loop_header: TokenStream, - span: Span, stmts: &[Stmt], body: &HtmlChildrenTree, deprecations: &TokenStream, ) -> TokenStream { - let acc = Ident::new("__yew_v", span); + let acc = Ident::new("__yew_v", Span::mixed_site()); let alloc_opt = body .size_hint() diff --git a/packages/yew-macro/src/html_tree/html_match.rs b/packages/yew-macro/src/html_tree/html_match.rs index 5ee53a88825..e94c4daf9ea 100644 --- a/packages/yew-macro/src/html_tree/html_match.rs +++ b/packages/yew-macro/src/html_tree/html_match.rs @@ -137,6 +137,29 @@ impl Parse for HtmlMatchArm { let comma: Option = input.parse()?; + // An unbraced `break`/`continue`/`return` body followed by more tokens + // past the optional comma is almost always an attempt to give the + // keyword an HTML value, which Rust does not accept as an expression. + // Without this check the next-arm parser runs on the trailing `<...>` + // and fails inside `Pat::parse` with a misleading "expected `>`". + if comma.is_none() && !input.is_empty() { + if let HtmlMatchArmBody::Unbraced { tree, .. } = &body { + if matches!( + &**tree, + super::HtmlTree::Break(_) + | super::HtmlTree::Continue(_) + | super::HtmlTree::Return(_) + ) { + return Err(syn::Error::new( + input.span(), + "`break`, `continue`, and `return` in a match arm cannot be followed by \ + HTML as their value. Wrap the arm body in braces, e.g. `_ => { return \ + ::yew::html!() }`.", + )); + } + } + } + Ok(HtmlMatchArm { pat, guard, diff --git a/packages/yew-macro/src/html_tree/html_while.rs b/packages/yew-macro/src/html_tree/html_while.rs index 940fe1d717f..68f7f50fe72 100644 --- a/packages/yew-macro/src/html_tree/html_while.rs +++ b/packages/yew-macro/src/html_tree/html_while.rs @@ -67,6 +67,6 @@ impl ToTokens for HtmlWhile { deprecations, } = self; let header = quote!(while #cond); - tokens.extend(emit_loop(header, cond.span(), stmts, body, deprecations)); + tokens.extend(emit_loop(header, stmts, body, deprecations)); } } diff --git a/packages/yew-macro/tests/html_macro/html-match-fail.rs b/packages/yew-macro/tests/html_macro/html-match-fail.rs index f300d7e3f10..a69577a9d9d 100644 --- a/packages/yew-macro/tests/html_macro/html-match-fail.rs +++ b/packages/yew-macro/tests/html_macro/html-match-fail.rs @@ -9,4 +9,44 @@ fn main() { // Empty match (no arms) html! { match 42 {} }; + + // `break` followed by HTML in an unbraced arm. Rust doesn't accept `` + // as an expression so it can't be a break value; user must wrap in braces. + let _ = html! { + for _ in 0..1 { + match () { + () => break , + } + } + }; + + // Same pattern with `continue`. + let _ = html! { + for _ in 0..1 { + match () { + () => continue
, + } + } + }; + + // Same pattern with `return`. + fn return_html() -> Html { + html! { + for _ in 0..1 { + match () { + () => return

, + } + } + } + } + let _ = return_html; + + // Final arm without trailing comma. + let _ = html! { + for _ in 0..1 { + match () { + () => break

+ } + } + }; } diff --git a/packages/yew-macro/tests/html_macro/html-match-fail.stderr b/packages/yew-macro/tests/html_macro/html-match-fail.stderr index ff8dda5d8a0..e0f865d152a 100644 --- a/packages/yew-macro/tests/html_macro/html-match-fail.stderr +++ b/packages/yew-macro/tests/html_macro/html-match-fail.stderr @@ -15,3 +15,27 @@ error: `match` expression must have at least one arm | 11 | html! { match 42 {} }; | ^^ + +error: `break`, `continue`, and `return` in a match arm cannot be followed by HTML as their value. Wrap the arm body in braces, e.g. `_ => { return ::yew::html!() }`. + --> tests/html_macro/html-match-fail.rs:18:29 + | +18 | () => break , + | ^ + +error: `break`, `continue`, and `return` in a match arm cannot be followed by HTML as their value. Wrap the arm body in braces, e.g. `_ => { return ::yew::html!() }`. + --> tests/html_macro/html-match-fail.rs:27:32 + | +27 | () => continue
, + | ^ + +error: `break`, `continue`, and `return` in a match arm cannot be followed by HTML as their value. Wrap the arm body in braces, e.g. `_ => { return ::yew::html!() }`. + --> tests/html_macro/html-match-fail.rs:37:34 + | +37 | () => return

, + | ^ + +error: `break`, `continue`, and `return` in a match arm cannot be followed by HTML as their value. Wrap the arm body in braces, e.g. `_ => { return ::yew::html!() }`. + --> tests/html_macro/html-match-fail.rs:48:29 + | +48 | () => break

+ | ^ diff --git a/packages/yew/tests/html_for.rs b/packages/yew/tests/html_for.rs index c2d7afdef55..6b6309437c0 100644 --- a/packages/yew/tests/html_for.rs +++ b/packages/yew/tests/html_for.rs @@ -295,3 +295,51 @@ async fn for_return_in_unbraced_match_arm() { assert_eq!(render_and_read::().await, "stopped at 3"); } + +// `break `, `continue `, and `return ` +// in an unbraced match arm are compile errors (see html-match-fail.rs). The +// documented workaround is to wrap the arm body in braces and use `html!(...)` +// to produce the value. The tests below verify the workaround compiles and +// renders correctly. +#[wasm_bindgen_test] +async fn for_return_html_workaround_with_braced_arm() { + #[component] + fn App() -> Html { + html! { +
+ for i in 0..10 { + match i { + 3 => { return html!(

{format!("stopped at {i}")}

) } + _ => {i}, + } + } +
+ } + } + + assert_eq!(render_and_read::().await, "stopped at 3"); +} + +#[wasm_bindgen_test] +async fn for_break_workaround_with_braced_arm() { + // Breaking the loop doesn't need a value; the workaround for users who + // tried `break ` is just a braced `{ break }` arm. + #[component] + fn App() -> Html { + html! { +
+ for i in 0..10 { + match i { + 3 => { break } + _ => {i}, + } + } +
+ } + } + + assert_eq!( + render_and_read::().await, + "012" + ); +} diff --git a/packages/yew/tests/html_while.rs b/packages/yew/tests/html_while.rs index ff3ded15b61..b2408e58e1a 100644 --- a/packages/yew/tests/html_while.rs +++ b/packages/yew/tests/html_while.rs @@ -340,3 +340,25 @@ async fn while_return_in_unbraced_match_arm() { assert_eq!(render_and_read::().await, "stopped at 3"); } + +// Counterpart to `for_return_html_workaround_with_braced_arm`: verifies the +// braced-arm workaround works under `while let` too. +#[wasm_bindgen_test] +async fn while_return_html_workaround_with_braced_arm() { + #[component] + fn App() -> Html { + let mut it = (0..10).into_iter(); + html! { +
+ while let Some(v) = it.next() { + match v { + 3 => { return html!(

{format!("stopped at {v}")}

) } + _ => {v}, + } + } +
+ } + } + + assert_eq!(render_and_read::().await, "stopped at 3"); +} From b9fe62a55487fae23ac5aa8047bd8c6892c33107 Mon Sep 17 00:00:00 2001 From: Matt Yan Date: Sat, 25 Apr 2026 18:30:02 +0900 Subject: [PATCH 8/8] feat(yew-macro): hint imperative for/while/loop in html preamble --- packages/yew-macro/src/html_tree/mod.rs | 43 ++++++++++++ .../html_macro/imperative-preamble-fail.rs | 70 +++++++++++++++++++ .../imperative-preamble-fail.stderr | 55 +++++++++++++++ website/docs/concepts/html/lists.mdx | 41 +++++++++++ 4 files changed, 209 insertions(+) create mode 100644 packages/yew-macro/tests/html_macro/imperative-preamble-fail.rs create mode 100644 packages/yew-macro/tests/html_macro/imperative-preamble-fail.stderr diff --git a/packages/yew-macro/src/html_tree/mod.rs b/packages/yew-macro/src/html_tree/mod.rs index fc1a0d5faf1..c2f6e626676 100644 --- a/packages/yew-macro/src/html_tree/mod.rs +++ b/packages/yew-macro/src/html_tree/mod.rs @@ -581,6 +581,15 @@ impl Parse for HtmlRootBraced { /// to it, which lets us fall through cleanly whenever the next token run is /// not Rust-parseable (e.g. an `` or `if cond { }` that the /// Rust expression grammar rejects). +/// +/// One pitfall: block-like Rust expressions (`for`, `while`, `loop`, `{...}`) +/// auto-terminate as statements per Rust grammar, so a trailing `;` is not +/// folded into the `Stmt`. Such expressions in preamble position parse as +/// `Stmt::Expr(_, None)` and fall through to the html-control-flow parser. +/// When the entire expression is Rust-parseable (no html elements anywhere +/// inside), the user almost certainly wrote an imperative loop/block, not an +/// html-emitting one - we surface a help message pointing at the +/// `let _ = ...;` workaround. pub(super) fn parse_preamble_stmts(input: ParseStream) -> syn::Result> { let mut stmts = Vec::new(); loop { @@ -590,6 +599,21 @@ pub(super) fn parse_preamble_stmts(input: ParseStream) -> syn::Result true, Ok(syn::Stmt::Expr(_, Some(_))) => true, Ok(syn::Stmt::Macro(m)) => m.semi_token.is_some(), + Ok(syn::Stmt::Expr(expr, None)) => { + if let Some(kind) = imperative_blocklike_kind(&expr) { + proc_macro_error::emit_error!( + expr, + "this `{}` block is fully Rust-parseable, so it is parsed as html-{} \ + here, but its body cannot produce any html nodes", + kind, kind; + help = "to run a Rust `{}` here for side effects only, bind it with \ + `let _ = ...;` so the parser sees a Rust statement: \ + `let _ = {} ... {{ ... }};`", + kind, kind + ); + } + false + } _ => false, }; if !is_preamble { @@ -601,6 +625,25 @@ pub(super) fn parse_preamble_stmts(input: ParseStream) -> syn::Result Option<&'static str> { + match expr { + syn::Expr::ForLoop(_) => Some("for"), + syn::Expr::While(_) => Some("while"), + syn::Expr::Loop(_) => Some("loop"), + _ => None, + } +} + /// Parse `break [label] [value]` forgivingly: first try syn's full /// `ExprBreak::parse` (via a fork so we don't commit a bad state), and if that /// fails because the value position starts with `<` (html) or another diff --git a/packages/yew-macro/tests/html_macro/imperative-preamble-fail.rs b/packages/yew-macro/tests/html_macro/imperative-preamble-fail.rs new file mode 100644 index 00000000000..b7c63ab6646 --- /dev/null +++ b/packages/yew-macro/tests/html_macro/imperative-preamble-fail.rs @@ -0,0 +1,70 @@ +// Tests for the diagnostic emitted when a block-like Rust expression +// (`for`, `while`, `loop`, `{...}`) appears in preamble position. These +// expressions auto-terminate as statements per Rust grammar, so a trailing +// `;` is not folded into the `Stmt`. The preamble parser rejects them +// (matching `Stmt::Expr(_, None)`) and they would otherwise fall through +// to the html-control-flow parser, where the body's `()` value fails to +// convert to `VNode`. The diagnostic surfaces the pitfall at the right +// span and points at the `let _ = ...;` workaround. + +fn main() { + // Imperative `for` in a `for` body preamble. + _ = ::yew::html! { + for x in 0..3_u32 { + let mut acc: u32 = 0; + for i in 0..x { + acc += i; + } + {acc} + } + }; + + // Imperative `while` in a `for` body preamble. + _ = ::yew::html! { + for _x in 0..3_u32 { + let mut counter: u32 = 0; + while counter < 5 { + counter += 1; + } + {counter} + } + }; + + // Imperative `loop` in a `for` body preamble. + _ = ::yew::html! { + for _x in 0..3_u32 { + let mut n: u32 = 0; + loop { + n += 1; + if n > 3 { break; } + } + {n} + } + }; + + // Imperative `for` in a `while` body preamble. + _ = ::yew::html! { + while false { + let mut acc: u32 = 0; + for i in 0..3_u32 { + acc += i; + } + {acc} + } + }; + + // Imperative `for` in a braced `match` arm preamble. + _ = ::yew::html! { + match 0_u32 { + 0 => { + let mut acc: u32 = 0; + for i in 0..3_u32 { + acc += i; + } + {acc} + } + _ => {"other"}, + } + }; + +} diff --git a/packages/yew-macro/tests/html_macro/imperative-preamble-fail.stderr b/packages/yew-macro/tests/html_macro/imperative-preamble-fail.stderr new file mode 100644 index 00000000000..3b6b2077350 --- /dev/null +++ b/packages/yew-macro/tests/html_macro/imperative-preamble-fail.stderr @@ -0,0 +1,55 @@ +error: this `for` block is fully Rust-parseable, so it is parsed as html-for here, but its body cannot produce any html nodes + + = help: to run a Rust `for` here for side effects only, bind it with `let _ = ...;` so the parser sees a Rust statement: `let _ = for ... { ... };` + + --> tests/html_macro/imperative-preamble-fail.rs:15:13 + | +15 | / for i in 0..x { +16 | | acc += i; +17 | | } + | |_____________^ + +error: this `while` block is fully Rust-parseable, so it is parsed as html-while here, but its body cannot produce any html nodes + + = help: to run a Rust `while` here for side effects only, bind it with `let _ = ...;` so the parser sees a Rust statement: `let _ = while ... { ... };` + + --> tests/html_macro/imperative-preamble-fail.rs:26:13 + | +26 | / while counter < 5 { +27 | | counter += 1; +28 | | } + | |_____________^ + +error: this `loop` block is fully Rust-parseable, so it is parsed as html-loop here, but its body cannot produce any html nodes + + = help: to run a Rust `loop` here for side effects only, bind it with `let _ = ...;` so the parser sees a Rust statement: `let _ = loop ... { ... };` + + --> tests/html_macro/imperative-preamble-fail.rs:37:13 + | +37 | / loop { +38 | | n += 1; +39 | | if n > 3 { break; } +40 | | } + | |_____________^ + +error: this `for` block is fully Rust-parseable, so it is parsed as html-for here, but its body cannot produce any html nodes + + = help: to run a Rust `for` here for side effects only, bind it with `let _ = ...;` so the parser sees a Rust statement: `let _ = for ... { ... };` + + --> tests/html_macro/imperative-preamble-fail.rs:49:13 + | +49 | / for i in 0..3_u32 { +50 | | acc += i; +51 | | } + | |_____________^ + +error: this `for` block is fully Rust-parseable, so it is parsed as html-for here, but its body cannot produce any html nodes + + = help: to run a Rust `for` here for side effects only, bind it with `let _ = ...;` so the parser sees a Rust statement: `let _ = for ... { ... };` + + --> tests/html_macro/imperative-preamble-fail.rs:61:17 + | +61 | / for i in 0..3_u32 { +62 | | acc += i; +63 | | } + | |_________________^ diff --git a/website/docs/concepts/html/lists.mdx b/website/docs/concepts/html/lists.mdx index ebdcb8556ec..7111aa90a9e 100644 --- a/website/docs/concepts/html/lists.mdx +++ b/website/docs/concepts/html/lists.mdx @@ -108,6 +108,47 @@ A bare qualified-path expression like `::method(...)` collides with the - Wrap it in `{...}` to use the return value as a node: `{ ::method(...) }`. +::: + +:::note Imperative loops in the preamble + +Block-like Rust expressions (`for`, `while`, `loop`, `{...}`) auto-terminate as +statements in Rust grammar - a trailing `;` is not folded into the statement. +A bare imperative loop in a preamble: + +```rust , ignore +html! { + for item in items.iter() { + let mut by_han = BTreeMap::new(); + for src in &item.sources { // imperative side-effect loop + by_han.entry(src.han_nom.clone()).or_default().push(src); + } +
{render(by_han)}
+ } +} +``` + +is parsed as html-`for` (emitting children) instead of as a Rust statement, +and its body's `()` value cannot be converted to a `VNode`. Bind it with +`let _ = ...;` so the parser sees a `Stmt::Local`: + +```rust , ignore +html! { + for item in items.iter() { + let mut by_han = BTreeMap::new(); + let _ = for src in &item.sources { + by_han.entry(src.han_nom.clone()).or_default().push(src); + }; +
{render(by_han)}
+ } +} +``` + +The same applies to imperative `while`, `loop`, and bare-block `{...}` +statements. `match` and `if` are not subject to this collision because their +html-control-flow forms naturally accept the same body shapes as their +imperative use. + :::