Attributes a component does not name
#[prop(rest)]: the bag every attribute that is not a prop lands in, and the rules that decide which is which.
A component tag may carry attributes the component has never heard of. They land in one field, and reach the page wherever the component’s template spreads it.
#[derive(Component, Default)]
#[component(default)]
pub struct Hidden {
pub value: Option<String>,
#[prop(rest)]
pub attrs: Attrs,
}<input type="hidden" value={self.value.clone()} {...self.attrs}><Hidden data-cover-target="input" aria-hidden="true" autofocus/>All three attributes reach the <input>. Without the bag each would have to be
a declared prop, or the call site would have to hand the component a string of
markup to splice.
The bag is opt-in
Only a component with a #[prop(rest)] field takes attributes it does not
declare. Everything else refuses them:
error[E0277]: this component takes only the props it declares
|
6 | <Card titel="Deploy finished"/>
| ^^^^^ there is no `#[prop(rest)]` field to carry an attribute the
| component does not nameThat is the point of the opt-in. A component that accepts everything cannot tell a passed-through attribute from a misspelled prop, so the typo above would be rendered into the page instead of reported.
A struct has at most one such field, and its type is
Attrs. It is never required: a bag nothing was put into
is an empty bag, so there is nothing for a call site to forget. That holds
without #[component(default)], which a component needs only for the reason it
always did — to fill its other props from Default.
Where a component does have one, a Default that seeds the bag is merged with
what the call site wrote, not replaced by it. A bag is a collection, and a call
site adds to it rather than writing the field; the alternative loses a
component’s own defaults the moment a call site writes any attribute at all,
with nothing saying so. A name written in both takes the call site’s value and
keeps the default’s position, which is what “a call site overrides a default”
means for every other prop.
Which attributes are props
A declared prop wins. value above is a prop, so value="…" is read by the
component and never reaches attrs. Everything else goes to the bag.
Nothing in the markup says which is which, and that is deliberate — a call site writes HTML attributes and the component decides what it knows about. Two rules follow from how the choice is actually made.
It is made when the template compiles, not while it runs. A template is
lowered in one crate and the component is compiled in another, so this pass
cannot see Hidden‘s fields. It therefore does not decide: it emits the setter
call and a fallback of the same name that routes to the bag, and lets Rust’s
method resolution choose between them. An inherent setter — a declared prop —
wins; a name with no setter falls through. The cost is a trait definition per
distinct attribute name, scoped to the one render function.
That fallback is implemented for props builders and nothing else, so a template
whose own code calls a method named after an attribute — {row.class()} beside a
<Icon class="…"/> — still reaches its own method. The bound is on the impl for
exactly that reason: a setter takes self by value, and a by-value candidate is
picked before a &self one.
A name that could not be a method is a bag attribute on sight. That is every
hyphenated name (data-cover-target, aria-label, x-on:click) and every Rust
keyword (type, for, async), since no struct field could be called either.
So <TextInput type="email"/> works even though type can never be a prop.
Which attributes reach the page
An element’s own attributes win over a spread. A tag that writes type
itself and also spreads a bag holding type writes it once, from the tag. The
alternative is a duplicate attribute, which is not valid HTML and which browsers
resolve by a rule nobody writing the template was thinking about.
<input type={self.kind()} {...self.attrs}>Here a type at the call site is dropped. A component that wants a call site to
override something declares it as a prop — which is exactly what a prop is for.
The set of names skipped is the literal attribute names in that tag, decided when
the template compiles, so what it costs at run time is a scan of a list that is
usually empty. This applies to every {...} spread, not only to a bag: see
Spreading.
What is in the bag
Attrs holds name/value pairs and nothing else — it is the type
{@attrs(…)} builds too, and it is keyed the same
way: a name given twice keeps its first position and takes its last
value, so a default a component filled in can be overridden without the output
reshuffling — and a name that could not be
written safely is dropped; see
Names that cannot be written.
A value is whatever IntoAttrValue accepts, which is the set
Attr covers and for the same reasons: a bool
renders a bare attribute or none at all, an Option renders nothing when it is
None, and there is no blanket impl over Display that would collide with
either.
let mut attrs = Attrs::new();
attrs.insert("aria-label", "Email address");
attrs.insert("autofocus", true); // a bare attribute
attrs.insert("disabled", false); // nothing at all
attrs.insert("readonly", None::<String>); // nothing at all
assert_eq!(attrs.get("aria-label"), Some("Email address"));
assert!(attrs.contains("autofocus")); // …though `get` gives it no valueget, contains and iter are there so a component can read what it was
given — merge a class, refuse an attribute, count them — rather than only pass
it on.
There is no markup in it
Attrs cannot hold a string. Attributes assembled into markup by hand were the
older spelling of this whole idea:
<Hidden attrs={r#"data-cover-target="input""#}/>and that is now a build failure, not a deprecation:
error[E0277]: `str` is not a set of attributes
= note: attributes written as raw markup are no longer accepted — write them
at the call site as attributes: `<Hidden data-controller="signup"/>`The reason is escaping. A bag escapes each name and each value on its own, so a
quote inside a value cannot end the attribute and begin another; a string
splices whole and can promise none of that. AttrSpread for &'static str still
exists for spreading onto an element, where the
'static bound is the guarantee — but a prop is not a place markup belongs.
Passing a whole set
attrs={…} takes a set rather than one attribute, through AttrSet: an Attrs,
a list of pairs, or an Option of either. So does {...expr} on a component
tag, which folds a set in where it was written:
<Passthrough {...&self.tracking}/>A wrapper forwards its own bag the same way, which is how a component that is really a thin layer over another one stays transparent:
<div class="field"><TextInput {...self.attrs}/></div>See also
- Props — the typed half, and why a prop is the way to override what a component writes itself.
- Attributes —
Attr, spreading, and the name rules the bag inherits. examples/attributesin the repository, which is all of the above running.