Skip to content

Using builder for component instantiation#183

Open
XX wants to merge 13 commits intovidhanio:mainfrom
XX:use_builder
Open

Using builder for component instantiation#183
XX wants to merge 13 commits intovidhanio:mainfrom
XX:use_builder

Conversation

@XX
Copy link

@XX XX commented Mar 2, 2026

This pull request changes the way user components are instantiated during the expansion of the rsx! and maud! macros. Previously, a struct-literal approach was used:

rsx! {
    <Component foo="foo" bar="bar" .. />
}
...
Component {
    foo: "foo",
    bar: "bar",
    ..Default::default()
}

Now a builder-based approach is used:

rsx! {
    <Component foo="foo" bar="bar" />
}
...
Component::builder()
    .foo("foo")
    .bar("bar")
    .build()

The builder methods can be:

  • Automatically derived with compile-time checks ensuring that all fields are initialized (by default using #[derive(Builder)] from the bon crate);
  • Generated according to the builder specified in the #[component] macro arguments (for example, #[renderable(builder = hypertext::DefaultBuilder)] or #[renderable(builder = bon::Builder)]);
  • Implemented manually by the user for a component type, with any custom behavior required.

It is also now possible to propagate attributes defined on the component function parameters to the fields of the generated struct. This allows specifying builder-specific field attributes, including default argument values.

Some usage examples are provided in two new tests: https://github.com/vidhanio/hypertext/pull/183/changes#diff-8c79c3d623f866c80fb5e4093f5e2b774c3d590025116a0352d4a240e1ed6817

Backward Compatibility

The changes preserve API backward compatibility as much as possible. However, in some aspects the new behavior is incompatible with the previous one:

  • It is no longer necessary to specify .. at the end if the component implements Default. This syntax has been removed from the component parser.
  • For components that implement Default, it is now necessary to explicitly specify the use of DefaultBuilder instead of the default TypedBuilder (i.e., #[component(builder = hypertext::DefaultBuilder)]) if omitting some component properties at the call site should be allowed.
  • The generated builders define the methods builder, build, and field setters, which may cause conflicts with similarly named methods already present in older components.
  • Attributes from the component constructor function parameters may be forwarded to the fields of the generated struct. The list of such attributes is expected in the attrs argument of the #[component] macro. By default, this list includes the builder attribute to allow passing configuration options to TypedBuilder.

Closes issue #180
Closes issue #128

@circuitsacul
Copy link

circuitsacul commented Mar 3, 2026

Nice, this works

use bon::bon;
use hypertext::prelude::*;

struct Component;

#[bon]
impl Component {
    #[builder]
    fn new(id: u64, optional: Option<String>, children: impl Renderable) -> impl Renderable {
        maud! { div #(id) { (optional) (children) } }
    }
}

fn main() {
    let res = maud! {
        Component id=1 { "hello" }
    }
    .render();

    println!("{res:?}"); // Rendered("<div id=\"1\">hello</div>")
}

I only wish there was a way to do it with bare functions, but unfortunately the generated API is function().id().call(), and while I can change .call to .build, I can't add in ::builder(). I can't think of a clean solution to this, at least not without making the maud/rsx macros aware of the actual type of the components. But that's alright, this already makes me very happy lol

I hope this gets merged

@thefiddler
Copy link

thefiddler commented Mar 5, 2026

Fantastic work!

I suspect this would potentially solve #123, which is currently a blocker for using hypertext in our codebase. Will check and report back.

Update: it does not fix hotpatching.

Update 2: this branch actually works fine with hotpatching! The issue is that dx serve from dioxus-cli has a special-case for the rsx! string that breaks hotpatching with hypertext. Using maud! on this branch, or renaming rsx! to html! with a simple macro works perfectly. 👍

@vidhanio
Copy link
Owner

vidhanio commented Mar 6, 2026

Hi there! Thank you so much for this work, it looks great! I would prefer that we use bon by default instead, would that be fine?

@XX
Copy link
Author

XX commented Mar 6, 2026

@vidhanio Yes, I can rewrite this to use bon. Should we leave the rest as is for now?

@vidhanio
Copy link
Owner

vidhanio commented Mar 6, 2026

@XX, yep! also, does this PR supersede your other one?

@vidhanio
Copy link
Owner

vidhanio commented Mar 6, 2026

also, please wait a sec as i will be adding in some changes to the rendering/parsing code based on #153.

@XX
Copy link
Author

XX commented Mar 6, 2026

@vidhanio Yes, this PR supersede the previous one.

@circuitsacul
Copy link

circuitsacul commented Mar 7, 2026

@vidhanio @XX This is just a thought I'm throwing out there. It might not make sense (particularly if it's just bon-by-default, and not bon-only)

If this is going to be bon-only, what do you think about using bon's function signature instead? I guess that would mean typed-builder would become incompatible, so that might not be acceptable. The signature is this

function().attr().attr().call()

as opposed to

Struct::builder().attr().attr().build()

You can of course change call to be whatever, but you can't make it a method. I think this has the benefit of being a little cleaner, but you can still add custom stuff to the generated builder.

For example @XX, I re-built your CommonAttrs, implementing it on the Builder. I wasn't able to do it via as_ref, but it's fairly close:

#[derive(Default)]
struct CommonAttrs {
    id: Cow<'static, str>,
    class: Cow<'static, str>,
}

trait CommonAttrsExt: Sized {
    fn attrs(&mut self) -> &mut CommonAttrs;

    fn id(mut self, id: impl Into<Cow<'static, str>>) -> Self {
        self.attrs().id = id.into();
        self
    }

    fn class(mut self, id: &str) -> Self {
        let mut old = self.attrs().class.to_string();
        old.push_str(id);
        self.attrs().class = Cow::Owned(old);
        self
    }
}

struct Component;

#[bon]
impl Component {
    #[builder]
    fn new(
        #[builder(field)] attrs: CommonAttrs,
        test: &str,
        children: impl Renderable,
    ) -> impl Renderable {
        maud! {
            div #(attrs.id) .(attrs.class) { (test) (children) }
        }
    }
}

impl<'a, R: Renderable, S: component_builder::State> CommonAttrsExt for ComponentBuilder<'a, R, S> {
    fn attrs(&mut self) -> &mut CommonAttrs {
        &mut self.attrs
    }
}

fn main() {
    let res = maud! {
        Component
            test="2"
            id="1"
            class="btn"
        { "hello" }
    }
    .render();

    println!("{res:?}"); // Rendered("<div id=\"1\" class=\"btn\">2hello</div>")
}

The function version would be basically the same, just a function instead of the unit struct of course. So you retain all the functionality of bon, but with one less struct def. And if you still want to do fully custom stuff, I imagine you can just do this:

struct MyBuilder {
    attr1, attr2
}

impl MyBuilder {
    fn call(self) -> impl Renderable { maud! { ... } }
}

fn CustomComponent() -> MyBuilder {
    MyBuilder::default()
}

So really, I don't think you lose anything other than support for typed-builder and other non-bon builders.

@XX
Copy link
Author

XX commented Mar 7, 2026

@vidhanio I have finally updated this PR, please take a look.

@vidhanio
Copy link
Owner

vidhanio commented Mar 7, 2026

@XX thanks! taking a look

}
}

#[renderable(builder = Builder, attrs = [builder])]
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

attrs is a bit ambiguous, and i don't think it needs to be an array. i think builder_attr = builder makes more sense. basically, i'd rather it be a single-value field named builder_attr.

Copy link
Author

@XX XX Mar 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The use of an array here is intentional: when implementing a custom builder or forwarding additional derives, a user may need to define a set of attributes that should be transferred to the struct fields (while the rest remain on the function arguments). For example:

#[renderable(attrs = [builder, serde])]
#[derive(Serialize)]
fn component(
    #[builder(default = 1)]
    tabindex: u32,
    #[serde(skip)]
    #[builder(default)]
    children: Lazy<fn(&mut Buffer)>,
) -> impl Renderable {
    rsx! {
        <div tabindex=(tabindex)>
            (children)
        </div>
    }
}

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It could be implemented so that the attr parameter accepts either a single value or an array of values.

Copy link
Owner

@vidhanio vidhanio Mar 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ah, i see. how about an interface like #[renderable(builder = DefaultBuilder, forwarded_attrs(serde, builder))]?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Alternatively, the condition could be inverted: all attributes of the function would be forwarded to the generated struct, while only those specified in an additional parameter #[renderable(fn_attrs = [..])] would remain on the function itself. This might be somewhat confusing, but it would likely be used less often.

Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yeah i think that's a good idea. do that, but use the fn_attrs(...) syntax.

Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

add tests for a component containing children: Lazy<F> where F: Fn(&mut Buffer). These tests all only would work for components where children do not capture any variables from the environment, which will rarely be the case in actual use. in #167 we already found that this wouldn't be possible when children must be Default, but that's okay we can just test for when it is required.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

4 participants