diff --git a/packages/rsx-hotreload/tests/hotreload_pattern.rs b/packages/rsx-hotreload/tests/hotreload_pattern.rs index 8a3a8c89f1..f87693e1b6 100644 --- a/packages/rsx-hotreload/tests/hotreload_pattern.rs +++ b/packages/rsx-hotreload/tests/hotreload_pattern.rs @@ -1301,3 +1301,38 @@ fn valid_spread() { assert!(valid); } + +// JSX-style syntax should hot reload, including when mixed with the regular syntax +#[test] +fn valid_jsx_syntax() { + // Literal changes in JSX-style rsx are hot reloadable + let valid = can_hotreload( + quote! { +
+

"Hello, world!"

+
+ }, + quote! { +
+

"Goodbye, world!"

+
+ }, + ); + assert!(valid); + + // Rewriting between the two syntax styles is hot reloadable since they + // produce the same templates + let valid = can_hotreload( + quote! { + div { class: "container", + h1 { "Hello, world!" } + } + }, + quote! { +
+

"Hello, world!"

+
+ }, + ); + assert!(valid); +} diff --git a/packages/rsx/src/component.rs b/packages/rsx/src/component.rs index 4c10edfbbe..966f3dc38f 100644 --- a/packages/rsx/src/component.rs +++ b/packages/rsx/src/component.rs @@ -56,23 +56,15 @@ impl Parse for Component { diagnostics, } = input.parse::()?; - let mut component = Self { - children: TemplateBody::new(children), + Ok(Self::from_parts( name, generics, fields, - brace: Some(brace), spreads, + children, + Some(brace), diagnostics, - }; - - // We've received a valid rsx block, but it's not necessarily a valid component - // validating it will dump diagnostics into the output - component.validate_component_path(); - component.validate_fields(); - component.validate_component_spread(); - - Ok(component) + )) } } @@ -121,6 +113,35 @@ impl Component { } } + /// Assemble a component from its parsed parts, running validation on the result + pub(crate) fn from_parts( + name: syn::Path, + generics: Option, + fields: Vec, + spreads: Vec, + children: Vec, + brace: Option, + diagnostics: Diagnostics, + ) -> Self { + let mut component = Self { + children: TemplateBody::new(children), + name, + generics, + fields, + brace, + spreads, + diagnostics, + }; + + // We've received a valid rsx block, but it's not necessarily a valid component + // validating it will dump diagnostics into the output + component.validate_component_path(); + component.validate_fields(); + component.validate_component_spread(); + + component + } + // Make sure this a proper component path (uppercase ident, a path, or contains an underscorea) // This should be validated by the RsxBlock parser when it peeks bodynodes fn validate_component_path(&mut self) { @@ -378,7 +399,7 @@ impl Component { /// Normalize the generics of a path /// /// Ensure there's a `::` after the last segment if there are generics -fn normalize_path(name: &mut syn::Path) -> Option { +pub(crate) fn normalize_path(name: &mut syn::Path) -> Option { let seg = name.segments.last_mut()?; let mut generics = match seg.arguments.clone() { diff --git a/packages/rsx/src/element.rs b/packages/rsx/src/element.rs index 952c530236..7653776acb 100644 --- a/packages/rsx/src/element.rs +++ b/packages/rsx/src/element.rs @@ -67,8 +67,46 @@ impl Parse for Element { ), } + Ok(Self::from_parts( + name, + block.attributes, + block.spreads, + block.children, + brace, + block.diagnostics, + )) + } +} + +impl ToTokens for Element { + fn to_tokens(&self, tokens: &mut TokenStream2) { + let builder = self.view_builder_pieces(); + let definitions = builder.definitions(); + let view = builder.view_expr(); + let diagnostics = &self.diagnostics; + + tokens.append_all(quote! { + { + #diagnostics + #(#definitions)* + #view + } + }); + } +} + +impl Element { + /// Assemble an element from its parsed parts, merging attributes and spreads + pub(crate) fn from_parts( + name: ElementName, + mut attributes: Vec, + spreads: Vec, + children: Vec, + brace: Option, + diagnostics: Diagnostics, + ) -> Self { // Make sure these attributes have element context for name and namespace resolution. - for attr in block.attributes.iter_mut() { + for attr in attributes.iter_mut() { attr.el_name = Some(name.clone()); } @@ -76,10 +114,10 @@ impl Parse for Element { let mut element = Element { brace, name: name.clone(), - raw_attributes: block.attributes, - children: block.children, - diagnostics: block.diagnostics, - spreads: block.spreads.clone(), + raw_attributes: attributes, + children, + diagnostics, + spreads: spreads.clone(), merged_attributes: Vec::new(), }; @@ -90,7 +128,7 @@ impl Parse for Element { // And then merge the spreads *after* the attributes are merged. This ensures walking the // merged attributes in path order stops before we hit the spreads, but spreads are still // counted as dynamic attributes - for spread in block.spreads.iter() { + for spread in spreads.iter() { element.merged_attributes.push(Attribute { name: AttributeName::Spread(spread.dots), colon: None, @@ -100,28 +138,9 @@ impl Parse for Element { }); } - Ok(element) - } -} - -impl ToTokens for Element { - fn to_tokens(&self, tokens: &mut TokenStream2) { - let builder = self.view_builder_pieces(); - let definitions = builder.definitions(); - let view = builder.view_expr(); - let diagnostics = &self.diagnostics; - - tokens.append_all(quote! { - { - #diagnostics - #(#definitions)* - #view - } - }); + element } -} -impl Element { pub(crate) fn add_merging_non_string_diagnostic(diagnostics: &mut Diagnostics, span: Span) { diagnostics.push(span.error("Cannot merge non-fmt literals").help( "Only formatted strings can be merged together. If you want to merge literals, you can use a format string.", diff --git a/packages/rsx/src/jsx.rs b/packages/rsx/src/jsx.rs new file mode 100644 index 0000000000..96bd4db3a7 --- /dev/null +++ b/packages/rsx/src/jsx.rs @@ -0,0 +1,530 @@ +//! JSX/XML-like syntax for the rsx! macro +//! +//! The rsx! macro also accepts a JSX/XML-like tag syntax which can be freely mixed with the +//! regular block-based syntax. A `<` token switches the parser into JSX mode: +//! +//! ```rust, ignore +//! rsx! { +//!
+//!

"Hello, world!"

+//! +//! "children" +//! +//! // The regular syntax can be used inside JSX children (and vice versa) +//! div { class: "inner", "More content" } +//! for item in items { +//! "{item}" +//! } +//!
+//! } +//! ``` +//! +//! The syntax follows the regular rsx! rules: +//! - Text nodes are quoted string literals (`

"Hello"

`) +//! - Attribute values are literals (`class="abc"`) or braced expressions (`onclick={move |_| ...}`) +//! - Shorthand attributes are supported (`
` is `class: class`) +//! - Spread attributes use `{..props}` +//! +//! The parsed result is the same [`Element`]/[`Component`] AST as the regular syntax, so +//! templates, hot-reloading, and diagnostics work identically. + +use crate::innerlude::*; +use proc_macro2::{Delimiter, Group, TokenStream as TokenStream2}; +use syn::{ + Expr, Ident, LitBool, LitFloat, LitInt, LitStr, Token, braced, + ext::IdentExt, + parse::{Parse, ParseBuffer, ParseStream}, + punctuated::Punctuated, + spanned::Spanned, + token::Brace, +}; + +/// Parse a JSX/XML-like tag into a [`BodyNode`]. Expects the stream to be pointing at a `<` token. +pub(crate) fn parse_jsx_node(stream: ParseStream) -> syn::Result { + let lt = stream.parse::()?; + + if stream.peek(Token![/]) { + return Err(syn::Error::new( + lt.span, + "encountered a closing tag without a matching opening tag", + )); + } + + if stream.peek(Token![>]) { + return Err(syn::Error::new( + lt.span, + "fragments (`<>`) are not supported - list the children directly instead", + )); + } + + // Decide between an element and a component using the same rules as the regular syntax: + // - idents followed by a dash are web components + // - a single lowercase ident with no underscores is an element + // - everything else is a component + let is_element = if stream.peek(Ident::peek_any) && stream.peek2(Token![-]) { + true + } else if stream.peek(Ident::peek_any) && !stream.peek2(Token![::]) { + let ident = parse_raw_ident(&stream.fork())?; + let name = ident.to_string(); + name.chars().next().unwrap().is_ascii_lowercase() && !name.contains('_') + } else { + false + }; + + if is_element { + parse_element(stream) + } else { + parse_component(stream) + } +} + +fn parse_element(stream: ParseStream) -> syn::Result { + let name = stream.parse::()?; + let (attributes, spreads) = parse_attributes(stream)?; + + let (brace, children) = parse_tag_end_and_children(stream, &name.to_string(), |close| { + let close_name = close.parse::()?; + if close_name != name { + return Err(syn::Error::new( + close_name.span(), + format!("closing tag `` does not match opening tag `<{name}>`"), + )); + } + Ok(()) + })?; + + Ok(BodyNode::Element(Element::from_parts( + name, + attributes, + spreads, + children, + Some(brace), + Diagnostics::new(), + ))) +} + +fn parse_component(stream: ParseStream) -> syn::Result { + let mut name = stream.parse::()?; + let generics = normalize_path(&mut name); + + let (fields, spreads) = parse_attributes(stream)?; + + let name_string = path_to_string(&name); + let (brace, children) = parse_tag_end_and_children(stream, &name_string, |close| { + let mut close_name = close.parse::()?; + normalize_path(&mut close_name); + if path_to_string(&close_name) != name_string { + return Err(syn::Error::new( + close_name.span(), + format!( + "closing tag `` does not match opening tag `<{}>`", + path_to_string(&close_name), + name_string + ), + )); + } + Ok(()) + })?; + + Ok(BodyNode::Component(Component::from_parts( + name, + generics, + fields, + spreads, + children, + Some(brace), + Diagnostics::new(), + ))) +} + +/// Parse the attributes of an open tag, stopping at `/>` or `>` +fn parse_attributes(stream: ParseStream) -> syn::Result<(Vec, Vec)> { + let mut attributes = Vec::new(); + let mut spreads = Vec::new(); + + loop { + if stream.peek(Token![/]) || stream.peek(Token![>]) { + break; + } + + if stream.is_empty() { + return Err(stream.error("expected `>` or `/>` to close the tag")); + } + + // Spread attributes: `{..expr}` + if stream.peek(Brace) { + let content: ParseBuffer; + braced!(content in stream); + let dots = content.parse::().map_err(|_| { + syn::Error::new( + content.span(), + "expected a spread attribute (`{..expr}`) - other braced expressions are not valid in a tag", + ) + })?; + let expr = content.parse::()?; + spreads.push(Spread { + dots, + expr, + comma: None, + }); + continue; + } + + // Attribute names are either string literals (custom attributes) or (dash-separated) idents + let name = if stream.peek(LitStr) { + AttributeName::Custom(stream.parse::()?) + } else { + let raw = Punctuated::::parse_separated_nonempty_with( + stream, + parse_raw_ident, + )?; + if raw.len() == 1 { + AttributeName::BuiltIn(raw.into_iter().next().unwrap()) + } else { + let span = raw.span(); + let name = raw + .into_iter() + .map(|ident| ident.to_string()) + .collect::>() + .join("-"); + AttributeName::Custom(LitStr::new(&name, span)) + } + }; + + let value = if stream.peek(Token![=]) { + stream.parse::()?; + + if stream.peek(Brace) { + // Braced expression values: `onclick={move |_| ...}`, `class={some_expr}` + let content: ParseBuffer; + braced!(content in stream); + let value = AttributeValue::parse(&content)?; + if !content.is_empty() { + return Err(content.error("unexpected tokens after attribute value")); + } + value + } else if stream.peek(LitStr) + || stream.peek(LitBool) + || stream.peek(LitFloat) + || stream.peek(LitInt) + { + // Literal values: `class="abc {def}"`, `width=100` + AttributeValue::AttrLiteral(stream.parse::()?) + } else { + return Err(stream.error( + "attribute values must be literals or expressions wrapped in braces (`attr={expr}`)", + )); + } + } else { + // Shorthand attributes: `
` is equivalent to `div { class }` + match &name { + AttributeName::BuiltIn(ident) => AttributeValue::Shorthand(ident.clone()), + _ => { + return Err(syn::Error::new( + name.span(), + "custom attributes must have a value", + )); + } + } + }; + + let mut attribute = Attribute::from_raw(name, value); + + // Attributes in tags don't have commas, but stray ones are accepted for + // compatibility with the regular syntax + attribute.comma = stream.parse::().ok(); + + attributes.push(attribute); + } + + Ok((attributes, spreads)) +} + +/// Parse the end of an open tag (`>` or `/>`), children, and the closing tag if there is one +/// +/// A brace token is synthesized from the span of the open tag's `>` so that consumers that expect +/// a braced body (completion hints, autofmt, ...) treat the tag as a complete node. +fn parse_tag_end_and_children( + stream: ParseStream, + name: &str, + parse_close_name: impl FnOnce(ParseStream) -> syn::Result<()>, +) -> syn::Result<(Brace, Vec)> { + // Self-closing tag: `
` + if stream.peek(Token![/]) { + stream.parse::()?; + let gt = stream.parse::]>()?; + return Ok((synthetic_brace(gt), Vec::new())); + } + + let gt = stream.parse::]>()?; + + let mut children = Vec::new(); + loop { + if stream.peek(Token![<]) && stream.peek2(Token![/]) { + break; + } + + if stream.is_empty() { + return Err(syn::Error::new( + gt.span(), + format!("missing closing tag ``"), + )); + } + + // Children of JSX tags are regular body nodes, so both syntaxes can be mixed freely + children.push(stream.parse::()?); + } + + stream.parse::()?; + stream.parse::()?; + parse_close_name(stream)?; + stream.parse::]>()?; + + Ok((synthetic_brace(gt), children)) +} + +fn synthetic_brace(gt: Token![>]) -> Brace { + let mut group = Group::new(Delimiter::Brace, TokenStream2::new()); + group.set_span(gt.span()); + Brace { + span: group.delim_span(), + } +} + +fn path_to_string(path: &syn::Path) -> String { + use quote::ToTokens; + let mut name = path.to_token_stream().to_string(); + name.retain(|c| !c.is_whitespace()); + name +} + +#[cfg(test)] +mod tests { + use super::*; + use quote::quote; + + fn parse(input: proc_macro2::TokenStream) -> BodyNode { + syn::parse2::(input).unwrap() + } + + #[test] + fn parses_basic_element() { + let node = parse(quote! {
"Hello"
}); + let BodyNode::Element(el) = node else { + panic!("expected element") + }; + assert_eq!(el.name, "div"); + assert_eq!(el.raw_attributes.len(), 1); + assert_eq!(el.raw_attributes[0].name.to_string(), "class"); + assert_eq!(el.children.len(), 1); + assert!(el.diagnostics.is_empty()); + } + + #[test] + fn parses_self_closing_element() { + let node = parse(quote! { }); + let BodyNode::Element(el) = node else { + panic!("expected element") + }; + assert_eq!(el.name, "img"); + assert!(el.children.is_empty()); + assert!(el.diagnostics.is_empty()); + } + + #[test] + fn parses_nested_elements() { + let node = parse(quote! { +
+

"Title"

+

"Body {text}"

+
+ }); + let BodyNode::Element(el) = node else { + panic!("expected element") + }; + assert_eq!(el.children.len(), 2); + } + + #[test] + fn parses_component() { + let node = parse(quote! { "children" }); + let BodyNode::Component(comp) = node else { + panic!("expected component") + }; + assert_eq!(comp.fields.len(), 1); + assert_eq!(comp.children.roots.len(), 1); + assert!(comp.diagnostics.is_empty()); + } + + #[test] + fn parses_component_path_and_generics() { + let node = parse(quote! { }); + assert!(matches!(node, BodyNode::Component(_))); + + let node = parse(quote! { /> }); + let BodyNode::Component(comp) = node else { + panic!("expected component") + }; + assert!(comp.generics.is_some()); + + let node = parse(quote! { >"child"> }); + let BodyNode::Component(comp) = node else { + panic!("expected component") + }; + assert!(comp.generics.is_some()); + assert_eq!(comp.children.roots.len(), 1); + } + + #[test] + fn parses_web_component() { + let node = parse(quote! { }); + let BodyNode::Element(el) = node else { + panic!("expected element") + }; + assert!(matches!(el.name, ElementName::Custom(_))); + } + + #[test] + fn parses_event_handlers_and_expressions() { + let node = parse(quote! { + + }); + let BodyNode::Element(el) = node else { + panic!("expected element") + }; + assert_eq!(el.raw_attributes.len(), 2); + assert!(matches!( + el.raw_attributes[0].value, + AttributeValue::EventTokens(_) + )); + assert!(matches!( + el.raw_attributes[1].value, + AttributeValue::AttrExpr(_) + )); + } + + #[test] + fn parses_shorthand_and_custom_attributes() { + let node = parse(quote! {
}); + let BodyNode::Element(el) = node else { + panic!("expected element") + }; + assert_eq!(el.raw_attributes.len(), 3); + assert!(matches!( + el.raw_attributes[0].value, + AttributeValue::Shorthand(_) + )); + assert_eq!(el.raw_attributes[1].name.to_string(), "data-count"); + assert_eq!(el.raw_attributes[2].name.to_string(), "custom-attr"); + } + + #[test] + fn parses_spreads() { + let node = parse(quote! {
}); + let BodyNode::Element(el) = node else { + panic!("expected element") + }; + assert_eq!(el.spreads.len(), 1); + + let node = parse(quote! { }); + let BodyNode::Component(comp) = node else { + panic!("expected component") + }; + assert_eq!(comp.spreads.len(), 1); + } + + #[test] + fn mixes_syntax_styles() { + // JSX children inside regular blocks + let node = parse(quote! { + div { + class: "outer", + "inner" + p { "regular" } + } + }); + let BodyNode::Element(el) = node else { + panic!("expected element") + }; + assert_eq!(el.children.len(), 2); + + // Regular blocks, expressions, and control flow inside JSX children + let node = parse(quote! { +
+ p { class: "regular", "regular" } + {some_expr} + for item in items { + "{item}" + } + if cond { + "conditional" + } + +
+ }); + let BodyNode::Element(el) = node else { + panic!("expected element") + }; + assert_eq!(el.children.len(), 5); + } + + #[test] + fn merges_attributes() { + let node = parse(quote! {
}); + let BodyNode::Element(el) = node else { + panic!("expected element") + }; + assert_eq!(el.merged_attributes.len(), 1); + assert!(el.diagnostics.is_empty()); + } + + #[test] + fn rejects_invalid_input() { + // Mismatched closing tag + assert!(syn::parse2::(quote! {
"hi" }).is_err()); + assert!(syn::parse2::(quote! { "hi" }).is_err()); + + // Missing closing tag + assert!(syn::parse2::(quote! {
"hi" }).is_err()); + + // Stray closing tag + assert!(syn::parse2::(quote! {
}).is_err()); + + // Fragments are not supported + assert!(syn::parse2::(quote! { <>"hi" }).is_err()); + + // Unbraced expression values + assert!(syn::parse2::(quote! {
}).is_err()); + } + + #[test] + fn compiles_to_template() { + use quote::ToTokens; + + let body: crate::CallBody = syn::parse2(quote! { +
+

"Hello, {name}!"

+ + +
+ }) + .unwrap(); + + let block: crate::CallBody = syn::parse2(quote! { + div { class: "container", + h1 { "Hello, {name}!" } + button { onclick: move |_| println!("clicked"), "Click" } + MyComponent { prop: "value" } + } + }) + .unwrap(); + + // Tag syntax should generate exactly the same code as the equivalent block syntax + assert_eq!( + body.to_token_stream().to_string(), + block.to_token_stream().to_string() + ); + } +} diff --git a/packages/rsx/src/lib.rs b/packages/rsx/src/lib.rs index 6b98d6976c..2afab2557f 100644 --- a/packages/rsx/src/lib.rs +++ b/packages/rsx/src/lib.rs @@ -20,6 +20,7 @@ mod element; mod fill_order; mod forloop; mod ifchain; +mod jsx; mod node; mod raw_expr; mod rsx_block; @@ -70,6 +71,7 @@ pub(crate) mod innerlude { pub use crate::expr_node::*; pub use crate::forloop::*; pub use crate::ifchain::*; + pub(crate) use crate::jsx::*; pub use crate::node::*; pub use crate::raw_expr::*; pub use crate::rsx_block::*; diff --git a/packages/rsx/src/node.rs b/packages/rsx/src/node.rs index c082ae1fa8..b89aa155fe 100644 --- a/packages/rsx/src/node.rs +++ b/packages/rsx/src/node.rs @@ -39,6 +39,11 @@ impl Parse for BodyNode { return Ok(BodyNode::Text(stream.parse()?)); } + // A `<` token switches into the JSX/XML-like tag syntax + if stream.peek(Token![<]) { + return parse_jsx_node(stream); + } + // Transform for loops into into_iter calls if stream.peek(Token![for]) { return Ok(BodyNode::ForLoop(stream.parse()?)); diff --git a/packages/rsx/src/raw_expr.rs b/packages/rsx/src/raw_expr.rs index 599a9516a7..e072b68e5a 100644 --- a/packages/rsx/src/raw_expr.rs +++ b/packages/rsx/src/raw_expr.rs @@ -22,19 +22,22 @@ impl Parse for PartialExpr { // - another braced group // - an identifier // - a string literal + // - a `<` (the start of a JSX-style tag) let mut is_braced = false; if let Some((TokenTree::Group(group), next)) = input.fork().cursor().token_tree() { let next_char_is_a_comma = next.punct().is_some_and(|(tt, _)| tt.as_char() == ','); let next_is_a_braced_exp = next.group(Delimiter::Brace).is_some(); let next_is_an_ident = next.ident().is_some(); let next_is_a_string_literal = next.literal().is_some(); + let next_is_a_lt = next.punct().is_some_and(|(tt, _)| tt.as_char() == '<'); if group.delimiter() == Delimiter::Brace && (next.eof() || next_char_is_a_comma || next_is_a_braced_exp || next_is_an_ident - || next_is_a_string_literal) + || next_is_a_string_literal + || next_is_a_lt) { is_braced = true } diff --git a/packages/ssr/tests/jsx_syntax.rs b/packages/ssr/tests/jsx_syntax.rs new file mode 100644 index 0000000000..b75822618b --- /dev/null +++ b/packages/ssr/tests/jsx_syntax.rs @@ -0,0 +1,77 @@ +#![allow(non_snake_case)] + +use dioxus::prelude::*; + +#[test] +fn jsx_elements() { + let name = "world"; + assert_eq!( + dioxus_ssr::render_element(rsx! { +
+

"Hello, {name}!"

+ +
+ }), + r#"

Hello, world!

"# + ); +} + +#[test] +fn jsx_components() { + #[component] + fn Wrapper(title: String, children: Element) -> Element { + rsx! { +
+

"{title}"

+ {children} +
+ } + } + + assert_eq!( + dioxus_ssr::render_element(rsx! { + +

"body"

+
+ }), + "

hi

body

" + ); +} + +#[test] +fn jsx_mixed_with_regular_syntax() { + let count = 3; + assert_eq!( + dioxus_ssr::render_element(rsx! { + div { class: "outer", + "jsx child" + p { "regular child" } + for i in 0..count { + "{i}" + } + if count > 1 { + "many" + } + } + }), + r#"
jsx child

regular child

012many
"# + ); +} + +#[test] +fn jsx_event_handlers_and_shorthand() { + fn App() -> Element { + let disabled = true; + rsx! { + + } + } + + let mut dom = VirtualDom::new(App); + dom.rebuild(&mut dioxus_core::NoOpMutations); + + assert_eq!( + dioxus_ssr::render(&dom), + "" + ); +}