Skip to content

Using builder for component instantiation#183

Merged
vidhanio merged 15 commits intovidhanio:mainfrom
XX:use_builder
Mar 8, 2026
Merged

Using builder for component instantiation#183
vidhanio merged 15 commits intovidhanio:mainfrom
XX:use_builder

Conversation

@XX
Copy link
Contributor

@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
Contributor 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
Contributor 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
Contributor 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

@vidhanio
Copy link
Owner

vidhanio commented Mar 8, 2026

Thank you so much! Working on now adding SVG on top of this.

@vidhanio vidhanio merged commit 8152230 into vidhanio:main Mar 8, 2026
14 of 15 checks passed
@github-actions github-actions bot mentioned this pull request Mar 7, 2026
vidhanio added a commit that referenced this pull request Mar 8, 2026
Co-authored-by: Vidhan Bhatt <me@vidhan.io>
thefiddler pushed a commit to thefiddler/hypertext that referenced this pull request Mar 8, 2026
- rsx_file! / rsx_file_borrow!: load RSX from external files
- html! / html_borrow! / html_file! / html_file_borrow!: rsx aliases
  that avoid Dioxus CLI name collision
- File paths resolve relative to CARGO_MANIFEST_DIR
- include_bytes! emitted for automatic recompilation on file changes
- Rebased on upstream/main (includes vidhanio#183 builder refactor)
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