Extend head components with global attributes (#2888)

* Fix hot reloading components with keys

* include component formatted segment keys, but not dynamic component value

* extend each head component with the corresponding element

* Allow spreading custom attributes into components

* Fix component_literal_dyn_idx index

* add a new test for hot reloading components with keys

* FIx script without body warning and rendering styles with a href set

* fix clippy
This commit is contained in:
Evan Almloff authored and GitHub committed 2024-10-01 10:24:22 -05:00
1 parent c2b131f249
commit 95976d9a17
8 files changed
+863 -1134

No files matched your search

Generated
+648 -1073
View File
File diff suppressed because it is too large. Load diff
+2 -1
View File
@@ -19,7 +19,8 @@ fn app() -> Element {
extra_data: "hello{1}", extra_data: "hello{1}",
extra_data2: "hello{2}", extra_data2: "hello{2}",
height: "10px", height: "10px",
left: 1 left: 1,
"data-custom-attribute": "value",
} }
} }
} }
+8 -8
View File
@@ -1017,21 +1017,21 @@ Finally, call `.build()` to create the instance of `{name}`.
impl #impl_generics dioxus_core::prelude::HasAttributes for #builder_name < #( #ty_generics ),* > #where_clause { impl #impl_generics dioxus_core::prelude::HasAttributes for #builder_name < #( #ty_generics ),* > #where_clause {
fn push_attribute( fn push_attribute(
mut self, mut self,
name: &'static str, ____name: &'static str,
ns: Option<&'static str>, ____ns: Option<&'static str>,
attr: impl dioxus_core::prelude::IntoAttributeValue, ____attr: impl dioxus_core::prelude::IntoAttributeValue,
volatile: bool ____volatile: bool
) -> Self { ) -> Self {
let ( #(#descructuring,)* ) = self.fields; let ( #(#descructuring,)* ) = self.fields;
self.#field_name.push( self.#field_name.push(
dioxus_core::Attribute::new( dioxus_core::Attribute::new(
name, ____name,
{ {
use dioxus_core::prelude::IntoAttributeValue; use dioxus_core::prelude::IntoAttributeValue;
attr.into_value() ____attr.into_value()
}, },
ns, ____ns,
volatile, ____volatile,
) )
); );
#builder_name { #builder_name {
+37 -1
View File
@@ -91,6 +91,7 @@ impl Document for ServerDocument {
http_equiv: props.http_equiv, http_equiv: props.http_equiv,
content: props.content, content: props.content,
property: props.property, property: props.property,
..props.additional_attributes
} }
}); });
} }
@@ -98,7 +99,7 @@ impl Document for ServerDocument {
fn create_script(&self, props: ScriptProps) { fn create_script(&self, props: ScriptProps) {
self.warn_if_streaming(); self.warn_if_streaming();
self.serialize_for_hydration(); self.serialize_for_hydration();
let children = props.script_contents(); let children = props.script_contents().ok();
self.0.borrow_mut().script.push(rsx! { self.0.borrow_mut().script.push(rsx! {
script { script {
src: props.src, src: props.src,
@@ -110,11 +111,46 @@ impl Document for ServerDocument {
nonce: props.nonce, nonce: props.nonce,
referrerpolicy: props.referrerpolicy, referrerpolicy: props.referrerpolicy,
r#type: props.r#type, r#type: props.r#type,
..props.additional_attributes,
{children} {children}
} }
}); });
} }
fn create_style(&self, props: StyleProps) {
self.warn_if_streaming();
self.serialize_for_hydration();
match (&props.href, props.style_contents()) {
// The style has inline contents, render it as a style tag
(_, Ok(contents)) => self.0.borrow_mut().script.push(rsx! {
style {
media: props.media,
nonce: props.nonce,
title: props.title,
..props.additional_attributes,
{contents}
}
}),
// The style has a href, render it as a link tag
(Some(_), _) => {
self.0.borrow_mut().script.push(rsx! {
link {
rel: "stylesheet",
href: props.href,
media: props.media,
nonce: props.nonce,
title: props.title,
..props.additional_attributes,
}
});
}
// The style has neither contents nor src, log an error
(None, Err(err)) => {
err.log("Style");
}
}
}
fn create_link(&self, props: head::LinkProps) { fn create_link(&self, props: head::LinkProps) {
self.warn_if_streaming(); self.warn_if_streaming();
self.serialize_for_hydration(); self.serialize_for_hydration();
+60 -21
View File
@@ -2,6 +2,7 @@
use std::{cell::RefCell, collections::HashSet, rc::Rc}; use std::{cell::RefCell, collections::HashSet, rc::Rc};
use crate as dioxus_elements;
use dioxus_core::{prelude::*, DynamicNode}; use dioxus_core::{prelude::*, DynamicNode};
use dioxus_core_macro::*; use dioxus_core_macro::*;
@@ -19,12 +20,42 @@ fn use_update_warning<T: PartialEq + Clone + 'static>(value: &T, name: &'static
} }
} }
fn extract_single_text_node(children: &Element, component: &str) -> Option<String> { /// An error that can occur when extracting a single text node from a component
pub enum ExtractSingleTextNodeError<'a> {
/// The node contained an render error, so we can't extract the text node
RenderError(&'a RenderError),
/// There was only one child, but it wasn't a text node
NonTextNode,
/// There is multiple child nodes
NonTemplate,
}
impl ExtractSingleTextNodeError<'_> {
/// Log a warning depending on the error
pub fn log(&self, component: &str) {
match self {
ExtractSingleTextNodeError::RenderError(err) => {
tracing::error!("Error while rendering {component}: {err}");
}
ExtractSingleTextNodeError::NonTextNode => {
tracing::error!(
"Error while rendering {component}: The children of {component} must be a single text node"
);
}
ExtractSingleTextNodeError::NonTemplate => {
tracing::error!(
"Error while rendering {component}: The children of {component} must be a single text node"
);
}
}
}
}
fn extract_single_text_node(children: &Element) -> Result<String, ExtractSingleTextNodeError<'_>> {
let vnode = match children { let vnode = match children {
Element::Ok(vnode) => vnode, Element::Ok(vnode) => vnode,
Element::Err(err) => { Element::Err(err) => {
tracing::error!("Error while rendering {component}: {err}"); return Err(ExtractSingleTextNodeError::RenderError(err));
return None;
} }
}; };
// The title's children must be in one of two forms: // The title's children must be in one of two forms:
@@ -37,7 +68,7 @@ fn extract_single_text_node(children: &Element, component: &str) -> Option<Strin
node_paths: &[], node_paths: &[],
attr_paths: &[], attr_paths: &[],
.. ..
} => Some(text.to_string()), } => Ok(text.to_string()),
// rsx! { "title: {dynamic_text}" } // rsx! { "title: {dynamic_text}" }
Template { Template {
roots: &[TemplateNode::Dynamic { id }], roots: &[TemplateNode::Dynamic { id }],
@@ -47,19 +78,11 @@ fn extract_single_text_node(children: &Element, component: &str) -> Option<Strin
} => { } => {
let node = &vnode.dynamic_nodes[id]; let node = &vnode.dynamic_nodes[id];
match node { match node {
DynamicNode::Text(text) => Some(text.value.clone()), DynamicNode::Text(text) => Ok(text.value.clone()),
_ => { _ => Err(ExtractSingleTextNodeError::NonTextNode),
tracing::error!("Error while rendering {component}: The children of {component} must be a single text node. It cannot be a component, if statement, loop, or a fragment");
None
}
} }
} }
_ => { _ => Err(ExtractSingleTextNodeError::NonTemplate),
tracing::error!(
"Error while rendering title: The children of title must be a single text node"
);
None
}
} }
} }
@@ -90,8 +113,12 @@ pub struct TitleProps {
#[component] #[component]
pub fn Title(props: TitleProps) -> Element { pub fn Title(props: TitleProps) -> Element {
let children = props.children; let children = props.children;
let Some(text) = extract_single_text_node(&children, "Title") else { let text = match extract_single_text_node(&children) {
return VNode::empty(); Ok(text) => text,
Err(err) => {
err.log("Title");
return VNode::empty();
}
}; };
// Update the title as it changes. NOTE: We don't use use_effect here because we need this to run on the server // Update the title as it changes. NOTE: We don't use use_effect here because we need this to run on the server
@@ -112,6 +139,7 @@ pub fn Title(props: TitleProps) -> Element {
VNode::empty() VNode::empty()
} }
#[non_exhaustive]
/// Props for the [`Meta`] component /// Props for the [`Meta`] component
#[derive(Clone, Props, PartialEq)] #[derive(Clone, Props, PartialEq)]
pub struct MetaProps { pub struct MetaProps {
@@ -120,6 +148,8 @@ pub struct MetaProps {
pub charset: Option<String>, pub charset: Option<String>,
pub http_equiv: Option<String>, pub http_equiv: Option<String>,
pub content: Option<String>, pub content: Option<String>,
#[props(extends = meta, extends = GlobalAttributes)]
pub additional_attributes: Vec<Attribute>,
} }
impl MetaProps { impl MetaProps {
@@ -179,6 +209,7 @@ pub fn Meta(props: MetaProps) -> Element {
VNode::empty() VNode::empty()
} }
#[non_exhaustive]
#[derive(Clone, Props, PartialEq)] #[derive(Clone, Props, PartialEq)]
pub struct ScriptProps { pub struct ScriptProps {
/// The contents of the script tag. If present, the children must be a single text node. /// The contents of the script tag. If present, the children must be a single text node.
@@ -193,6 +224,8 @@ pub struct ScriptProps {
pub nonce: Option<String>, pub nonce: Option<String>,
pub referrerpolicy: Option<String>, pub referrerpolicy: Option<String>,
pub r#type: Option<String>, pub r#type: Option<String>,
#[props(extends = script, extends = GlobalAttributes)]
pub additional_attributes: Vec<Attribute>,
} }
impl ScriptProps { impl ScriptProps {
@@ -228,8 +261,8 @@ impl ScriptProps {
attributes attributes
} }
pub fn script_contents(&self) -> Option<String> { pub fn script_contents(&self) -> Result<String, ExtractSingleTextNodeError<'_>> {
extract_single_text_node(&self.children, "Script") extract_single_text_node(&self.children)
} }
} }
@@ -277,6 +310,7 @@ pub fn Script(props: ScriptProps) -> Element {
VNode::empty() VNode::empty()
} }
#[non_exhaustive]
#[derive(Clone, Props, PartialEq)] #[derive(Clone, Props, PartialEq)]
pub struct StyleProps { pub struct StyleProps {
/// Styles are deduplicated by their href attribute /// Styles are deduplicated by their href attribute
@@ -286,6 +320,8 @@ pub struct StyleProps {
pub title: Option<String>, pub title: Option<String>,
/// The contents of the style tag. If present, the children must be a single text node. /// The contents of the style tag. If present, the children must be a single text node.
pub children: Element, pub children: Element,
#[props(extends = style, extends = GlobalAttributes)]
pub additional_attributes: Vec<Attribute>,
} }
impl StyleProps { impl StyleProps {
@@ -306,8 +342,8 @@ impl StyleProps {
attributes attributes
} }
pub fn style_contents(&self) -> Option<String> { pub fn style_contents(&self) -> Result<String, ExtractSingleTextNodeError<'_>> {
extract_single_text_node(&self.children, "Title") extract_single_text_node(&self.children)
} }
} }
@@ -357,6 +393,7 @@ pub fn Style(props: StyleProps) -> Element {
use super::*; use super::*;
#[non_exhaustive]
#[derive(Clone, Props, PartialEq)] #[derive(Clone, Props, PartialEq)]
pub struct LinkProps { pub struct LinkProps {
pub rel: Option<String>, pub rel: Option<String>,
@@ -374,6 +411,8 @@ pub struct LinkProps {
pub integrity: Option<String>, pub integrity: Option<String>,
pub r#type: Option<String>, pub r#type: Option<String>,
pub blocking: Option<String>, pub blocking: Option<String>,
#[props(extends = link, extends = GlobalAttributes)]
pub additional_attributes: Vec<Attribute>,
} }
impl LinkProps { impl LinkProps {
+26 -3
View File
@@ -61,14 +61,37 @@ pub trait Document {
/// Create a new script tag /// Create a new script tag
fn create_script(&self, props: ScriptProps) { fn create_script(&self, props: ScriptProps) {
let attributes = props.attributes(); let attributes = props.attributes();
let js = create_element_in_head("script", &attributes, props.script_contents()); let js = match (&props.src, props.script_contents()) {
// The script has inline contents, render it as a script tag
(_, Ok(contents)) => create_element_in_head("script", &attributes, Some(contents)),
// The script has a src, render it as a script tag without a body
(Some(_), _) => create_element_in_head("script", &attributes, None),
// The script has neither contents nor src, log an error
(None, Err(err)) => {
err.log("Script");
return;
}
};
self.new_evaluator(js); self.new_evaluator(js);
} }
/// Create a new style tag /// Create a new style tag
fn create_style(&self, props: StyleProps) { fn create_style(&self, props: StyleProps) {
let attributes = props.attributes(); let mut attributes = props.attributes();
let js = create_element_in_head("style", &attributes, props.style_contents()); let js = match (&props.href, props.style_contents()) {
// The style has inline contents, render it as a style tag
(_, Ok(contents)) => create_element_in_head("style", &attributes, Some(contents)),
// The style has a src, render it as a link tag
(Some(_), _) => {
attributes.push(("type", "text/css".into()));
create_element_in_head("link", &attributes, None)
}
// The style has neither contents nor src, log an error
(None, Err(err)) => {
err.log("Style");
return;
}
};
self.new_evaluator(js); self.new_evaluator(js);
} }
+39 -27
View File
@@ -24,7 +24,7 @@ use std::{collections::HashSet, vec};
use syn::{ use syn::{
parse::{Parse, ParseStream}, parse::{Parse, ParseStream},
spanned::Spanned, spanned::Spanned,
token, AngleBracketedGenericArguments, Expr, PathArguments, Result, token, AngleBracketedGenericArguments, Expr, Ident, PathArguments, Result,
}; };
#[derive(PartialEq, Eq, Clone, Debug)] #[derive(PartialEq, Eq, Clone, Debug)]
@@ -179,29 +179,25 @@ impl Component {
/// Ensure there's no duplicate props - this will be a compile error but we can move it to a /// Ensure there's no duplicate props - this will be a compile error but we can move it to a
/// diagnostic, thankfully /// diagnostic, thankfully
///
/// Also ensure there's no stringly typed props
fn validate_fields(&mut self) { fn validate_fields(&mut self) {
let mut seen = HashSet::new(); let mut seen = HashSet::new();
for field in self.fields.iter() { for field in self.fields.iter() {
match &field.name { match &field.name {
AttributeName::Custom(name) => self.diagnostics.push( AttributeName::Custom(_) => {}
name.span()
.error("Custom attributes are not supported for Components. Only known attributes are allowed."),
),
AttributeName::BuiltIn(k) => { AttributeName::BuiltIn(k) => {
if !seen.contains(k) { if !seen.contains(k) {
seen.insert(k); seen.insert(k);
} else { } else {
self.diagnostics.push( self.diagnostics.push(k.span().error(
k.span() "Duplicate prop field found. Only one prop field per name is allowed.",
.error("Duplicate prop field found. Only one prop field per name is allowed."), ));
);
} }
}, }
AttributeName::Spread(_) => { AttributeName::Spread(_) => {
unreachable!("Spread attributes should be handled in the spread validation step.") unreachable!(
"Spread attributes should be handled in the spread validation step."
)
} }
} }
} }
@@ -222,13 +218,9 @@ impl Component {
quote! { fc_to_builder(#name #generics) } quote! { fc_to_builder(#name #generics) }
}; };
for (name, value) in self.make_field_idents() { tokens.append_all(self.add_fields_to_builder(
if manual_props.is_some() { manual_props.map(|_| Ident::new("__manual_props", proc_macro2::Span::call_site())),
tokens.append_all(quote! { __manual_props.#name = #value; }) ));
} else {
tokens.append_all(quote! { .#name(#value) })
}
}
if !self.children.is_empty() { if !self.children.is_empty() {
let children = &self.children; let children = &self.children;
@@ -259,11 +251,11 @@ impl Component {
.filter(move |attr| !attr.name.is_likely_key()) .filter(move |attr| !attr.name.is_likely_key())
} }
fn make_field_idents(&self) -> Vec<(TokenStream2, TokenStream2)> { fn add_fields_to_builder(&self, manual_props: Option<Ident>) -> TokenStream2 {
let mut dynamic_literal_index = 0; let mut dynamic_literal_index = 0;
self.component_props() let mut tokens = TokenStream2::new();
.map(|attribute| { for attribute in self.component_props() {
let release_value = attribute.value.to_token_stream(); let release_value = attribute.value.to_token_stream();
// In debug mode, we try to grab the value from the dynamic literal pool if possible // In debug mode, we try to grab the value from the dynamic literal pool if possible
let value = if let AttributeValue::AttrLiteral(literal) = &attribute.value { let value = if let AttributeValue::AttrLiteral(literal) = &attribute.value {
@@ -286,9 +278,29 @@ impl Component {
release_value release_value
}; };
(attribute.name.to_token_stream(), value) match &attribute.name {
}) AttributeName::BuiltIn(name) => {
.collect() if let Some(manual_props) = &manual_props {
tokens.append_all(quote! { #manual_props.#name = #value; })
} else {
tokens.append_all(quote! { .#name(#value) })
}
}
AttributeName::Custom(name) => {
if manual_props.is_some() {
tokens.append_all(name.span().error(
"Custom attributes are not supported for components that are spread",
).emit_as_expr_tokens());
} else {
tokens.append_all(quote! { .push_attribute(#name, None, #value, false) })
}
}
// spreads are handled elsewhere
AttributeName::Spread(_) => {}
}
}
tokens
} }
fn empty(name: syn::Path, generics: Option<AngleBracketedGenericArguments>) -> Self { fn empty(name: syn::Path, generics: Option<AngleBracketedGenericArguments>) -> Self {
+43
View File
@@ -0,0 +1,43 @@
use dioxus::prelude::*;
#[test]
fn spread() {
let dom = VirtualDom::prebuilt(app);
let html = dioxus_ssr::render(&dom);
assert_eq!(
html,
r#"<audio data-custom-attribute="value" style="width:10px;height:10px;left:1;">1: hello1
2: hello2</audio>"#
);
}
fn app() -> Element {
rsx! {
SpreadableComponent {
width: "10px",
extra_data: "hello{1}",
extra_data2: "hello{2}",
height: "10px",
left: 1,
"data-custom-attribute": "value",
}
}
}
#[derive(Props, PartialEq, Clone)]
struct Props {
#[props(extends = GlobalAttributes)]
attributes: Vec<Attribute>,
extra_data: String,
extra_data2: String,
}
#[component]
fn SpreadableComponent(props: Props) -> Element {
rsx! {
audio { ..props.attributes, "1: {props.extra_data}\n2: {props.extra_data2}" }
}
}