Skip to content

feat!: pass component children by reference, with ref/move markers to choose#223

Open
XX wants to merge 2 commits into
vidhanio:mainfrom
XX:children-ref
Open

feat!: pass component children by reference, with ref/move markers to choose#223
XX wants to merge 2 commits into
vidhanio:mainfrom
XX:children-ref

Conversation

@XX

@XX XX commented Jul 26, 2026

Copy link
Copy Markdown
Contributor

Motivation

This change grew out of a practical problem: shrinking the wasm bundle when
hypertext is used on the client.

Every component generic over its children type is monomorphized once per nested
block at the call site. In an app with a tree of nested components this grows
combinatorially, and the wasm binary ends up carrying a separate copy of the
rendering code for close to every combination. Moving to
children: &dyn Renderable replaces monomorphization with dynamic dispatch: one
copy of a component's code serves all call sites. In practice this cut the binary
size by several times.

For &dyn Renderable to be usable as a children parameter type at all, the
macro has to pass the block by reference — which is what the rest of this PR is
about.

Problem

When a component is invoked with a nested block, maud!/rsx! compile that block
into a Lazy<_> and hand it to the component's children setter. How it is handed
over is hardcoded in the code generator, so it fits some components and not others:

  • children: &'a dyn Renderable needs it by reference — that is the only way
    the &Lazy<_> → &dyn Renderable unsizing coercion applies;
  • children: Lazy<fn(&mut Buffer)> needs it by value — the component owns the
    block and can outlive the calling statement.

Both cannot be supported as things stand: any fixed choice rules out half of the
useful children signatures. And dropping by-value passing is not an option
either — it is what components that must own their children rely on, and on the
server the binary-size win does not matter.

Solution

The call site chooses, with a ref or move marker given as the last
attribute of the component:

// by reference: `&Lazy<_>` coerces to `&dyn Renderable`
#[renderable]
fn by_ref<'a>(children: &'a dyn Renderable) -> impl Renderable {
    maud! { div { (children) } }
}

maud! {
    ByRef ref {
        span { "borrowed" }
    }
}
// by value: the component owns the block
#[renderable(builder = DefaultBuilder)]
#[derive(Default)]
fn by_move(children: Lazy<fn(&mut Buffer)>) -> impl Renderable {
    maud! { div { (children) } }
}

rsx! {
    <ByMove move>
        <span>"owned"</span>
    </ByMove>
}

The markers behave identically in both syntaxes, maud! and rsx!.

With no marker, children are passed by reference. The new children-move
feature flips the default to by value; explicit markers always override the
default, so both styles stay available in either configuration. A wasm build can
keep the by-reference default while server-side code turns on children-move,
with no change to the markup itself.

ref and move are Rust keywords, so they can never be confused with an
attribute name — a struct field cannot be called ref or move either. maud!
already relies on the same property.

Diagnostics

Two errors, both with precise spans:

  • `ref` must be the last attribute of the component — when other attributes
    follow the marker;
  • `move` requires a children block to apply to — when the marker is put on a
    component with no nested block (<Component move />).

Backward compatibility

The default changes from by-value to by-reference. Components that store children
by value (e.g. Lazy<fn(&mut Buffer)>) will need an explicit move at the call
site after this change — or the children-move feature, which restores the
previous default across the dependency graph. Components taking children: &R or
&dyn Renderable keep working unchanged and without markers.

Testing

  • crates/hypertext/tests/components.rs: tests for ref and move in both
    syntaxes, for a component that only accepts by value (Lazy<fn(&mut Buffer)>),
    and for one that only accepts by reference (&dyn Renderable); the default is
    covered by two tests made mutually exclusive with
    #[cfg(feature = "children-move")].
  • cargo test --test components and cargo test --test components --features children-move — 46 tests each, all green.
  • Docs for #[renderable] gained a "children parameter type → required passing
    mode" table plus two working examples.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant