Skip to content

Commit ac576b5

Browse files
committed
Let a component declare children: Vec<Element> as an alternative to Element
Closes the gap in #1177 ("Iterable Children"): today the only way to get a caller's children as independent, individually-wrappable values is a hand-built Vec<Element> field populated with explicit vec![]/ rsx!{} ceremony at every call site (the issue's own documented workaround). Natural rsx! composition (bare children, for loops, if chains) only ever produces one opaque merged Element. This makes both declared shapes accept the exact same call-site syntax, with zero change to how a caller writes children: ListItem { label: "Phone", "555-1234" "555-5678" } works identically whether ListItem declares `children: Element` (merged, as today) or `children: Vec<Element>` (one entry per top-level child, for/if flattened to however many elements they actually produce). Mechanism: `rsx!`'s component-children codegen (dioxus-rsx's component.rs) now always compiles a component's children into a Vec<Element> - each non-control-flow root becomes its own standalone single-root template (TemplateBody::to_vec_tokens, reusing the existing single-Element codegen unchanged), a `for` root recurses into its body and flat_maps per iteration, and an `if`/`else` chain unifies its branches to Vec<Element> directly (IfChain::to_vec_tokens) instead of merging through IntoDynNode. The macro has no visibility into the callee's declared field type (macros expand before type-checking), so it can't choose per-callee - instead the callee side resolves it: dioxus-core-macro now forces the `children` field to always go through SuperInto (like #[props(into)]) regardless of whether the struct author wrote #[props(into)], and dioxus_core::properties gains SuperFrom<Vec<Element>, _> impls for both `Element` and `Option<Element>` that merge the vec back into one node through the same dynamic-node-slot machinery a `for` loop's own output already uses (unwrapping directly for the 1-element case, so the common single-child path produces the exact same VNode shape as before). The two hand-rolled Properties implementations in the whole workspace that predate the derive macro's current shape (Portal, SuspenseBoundary) had exact-type `children: Element` setters that would otherwise be the one place still broken by this - both updated to accept `impl SuperInto<Element, M>` too, mirroring what their own `fallback`-style setters already did. Verified via packages/core/tests/vec_children.rs (single/multiple/nested for+if/empty-default, both Element and Option<Element> targets), the full existing dioxus-core/dioxus-rsx/dioxus-core-macro/dioxus-html/dioxus-hooks/ dioxus-ssr/dioxus test suites (all green, including doctests with --all-features), cargo fmt --all --check, and cargo clippy --all-features --all-targets -- -D warnings on every crate this touches - no regressions.
1 parent 24f6a82 commit ac576b5

8 files changed

Lines changed: 465 additions & 17 deletions

File tree

packages/core-macro/src/props/mod.rs

Lines changed: 47 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -172,7 +172,10 @@ mod util {
172172
}
173173

174174
mod field_info {
175-
use crate::props::{looks_like_store_type, looks_like_write_type, type_from_inside_option};
175+
use crate::props::{
176+
looks_like_store_type, looks_like_write_type, type_from_inside_option,
177+
type_is_vec_of_elements,
178+
};
176179
use proc_macro2::TokenStream;
177180
use quote::{format_ident, quote};
178181
use syn::spanned::Spanned;
@@ -204,10 +207,24 @@ mod field_info {
204207
let strip_option_auto = builder_attr.strip_option
205208
|| !builder_attr.ignore_option && type_from_inside_option(&field.ty).is_some();
206209

207-
// children field is automatically defaulted to an empty VNode unless it is marked as optional (in which case it defaults to None)
208-
if name == "children" && !strip_option_auto {
209-
builder_attr.default =
210-
Some(syn::parse(quote!(dioxus_core::VNode::empty()).into()).unwrap());
210+
if name == "children" {
211+
// The call site always builds a `Vec<Element>` (see `dioxus-rsx`'s
212+
// `component.rs`), so the setter takes `impl SuperInto<_>` like
213+
// `#[props(into)]` does - that's what lets the field declare either
214+
// `Element` or `Vec<Element>` and still accept the same call-site syntax.
215+
builder_attr.auto_into = true;
216+
217+
// children is automatically defaulted to an empty VNode - or an empty Vec,
218+
// for a `children: Vec<Element>` field - unless it is marked as optional (in
219+
// which case it defaults to None)
220+
if !strip_option_auto {
221+
let default = if type_is_vec_of_elements(&field.ty) {
222+
quote!(::std::vec::Vec::new())
223+
} else {
224+
quote!(dioxus_core::VNode::empty())
225+
};
226+
builder_attr.default = Some(syn::parse(default.into()).unwrap());
227+
}
211228
}
212229

213230
// String fields automatically use impl Display
@@ -497,6 +514,31 @@ mod field_info {
497514
}
498515
}
499516

517+
/// Whether this is a `Vec<Element>`, however `Vec` and `Element` are spelled or pathed
518+
fn type_is_vec_of_elements(ty: &Type) -> bool {
519+
let Type::Path(type_path) = ty else {
520+
return false;
521+
};
522+
523+
let Some(seg) = type_path.path.segments.last() else {
524+
return false;
525+
};
526+
527+
if seg.ident != "Vec" {
528+
return false;
529+
}
530+
531+
let Some(Type::Path(inner)) = extract_inner_type_from_segment(seg) else {
532+
return false;
533+
};
534+
535+
inner
536+
.path
537+
.segments
538+
.last()
539+
.is_some_and(|seg| seg.ident == "Element")
540+
}
541+
500542
fn type_from_inside_option(ty: &Type) -> Option<&Type> {
501543
let Type::Path(type_path) = ty else {
502544
return None;

packages/core/src/portal.rs

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -93,11 +93,19 @@ impl<__children> PortalPropsBuilder<((), __children)> {
9393

9494
#[allow(dead_code, non_camel_case_types, missing_docs)]
9595
impl<__target> PortalPropsBuilder<(__target, ())> {
96+
/// Takes `impl SuperInto<Element, _>` rather than a bare `Element`, mirroring what
97+
/// `#[derive(Props)]` now generates for every `children: Element` field - the call site
98+
/// compiles children to a `Vec<Element>` (see
99+
/// [`crate::properties::VecElementFromMarker`]), so a bare `Element` here wouldn't accept
100+
/// them.
96101
#[allow(clippy::type_complexity)]
97-
pub fn children(self, children: Element) -> PortalPropsBuilder<(__target, (Element,))> {
102+
pub fn children<__Marker>(
103+
self,
104+
children: impl crate::SuperInto<Element, __Marker>,
105+
) -> PortalPropsBuilder<(__target, (Element,))> {
98106
let (target, _) = self.fields;
99107
PortalPropsBuilder {
100-
fields: (target, (children,)),
108+
fields: (target, (children.super_into(),)),
101109
_phantom: self._phantom,
102110
}
103111
}
@@ -147,9 +155,9 @@ impl<RenderFn, ComponentMarker, __target>
147155
PortalComponentBuilder<RenderFn, ComponentMarker, (__target, ())>
148156
{
149157
#[allow(clippy::type_complexity)]
150-
pub fn children(
158+
pub fn children<__Marker>(
151159
self,
152-
children: Element,
160+
children: impl crate::SuperInto<Element, __Marker>,
153161
) -> PortalComponentBuilder<RenderFn, ComponentMarker, (__target, (Element,))> {
154162
PortalComponentBuilder {
155163
render_fn: self.render_fn,

packages/core/src/properties.rs

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -356,6 +356,48 @@ where
356356
}
357357
}
358358

359+
/// Marker used to merge the `Vec<Element>` an `rsx!` body compiles its children to back into a
360+
/// single [`Element`], for a component that declares `children: Element`. Together with the
361+
/// `Vec<Element>` shape passing straight through, this is what makes both declarations accept the
362+
/// exact same call-site syntax.
363+
#[doc(hidden)]
364+
pub struct VecElementFromMarker;
365+
366+
/// `None` only for a genuinely empty list, never for a render error - `IntoDynNode for Element`
367+
/// already treats an `Err` as "nothing" rather than propagating it
368+
fn merge_vnodes(input: Vec<Element>) -> Option<VNode> {
369+
let mut nodes: Vec<VNode> = input.into_iter().map(IntoVNode::into_vnode).collect();
370+
match nodes.len() {
371+
0 => None,
372+
// One child, the common case, keeps exactly the VNode shape `children: Element` has
373+
1 => Some(nodes.pop().unwrap()),
374+
// 2+ separately built VNodes share no static Template, so they can only be combined at
375+
// runtime, through the same dynamic-node slot a `for` loop's output already uses
376+
_ => {
377+
use crate::view::{ViewExt, dynamic_node_builder};
378+
Some(dynamic_node_builder::<_, ()>(DynamicNode::Fragment(nodes)).into_vnode())
379+
}
380+
}
381+
}
382+
383+
impl SuperFrom<Vec<Element>, VecElementFromMarker> for Element {
384+
fn super_from(input: Vec<Element>) -> Self {
385+
// An empty list lands on `VNode::empty()`, the `children: Element` default
386+
Ok(merge_vnodes(input).unwrap_or_default())
387+
}
388+
}
389+
390+
/// Marker used to merge a `Vec<Element>` into `Option<Element>`, for a component whose `children`
391+
/// is itself optional - `None` for an empty list, same as that field's own default.
392+
#[doc(hidden)]
393+
pub struct VecElementFromOptionMarker;
394+
395+
impl SuperFrom<Vec<Element>, VecElementFromOptionMarker> for Option<Element> {
396+
fn super_from(input: Vec<Element>) -> Self {
397+
merge_vnodes(input).map(Ok)
398+
}
399+
}
400+
359401
/// Marker used to convert `&str` into `Option<String>` through [`SuperFrom`].
360402
#[doc(hidden)]
361403
pub struct OptionStringFromMarker;

packages/core/src/suspense/component.rs

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -105,9 +105,9 @@ impl<RenderFn, ComponentMarker, __fallback>
105105
{
106106
#[allow(clippy::type_complexity)]
107107
#[doc(hidden)]
108-
pub fn children(
108+
pub fn children<__Marker>(
109109
self,
110-
children: Element,
110+
children: impl SuperInto<Element, __Marker>,
111111
) -> SuspenseBoundaryComponentBuilder<RenderFn, ComponentMarker, (__fallback, (Element,))> {
112112
SuspenseBoundaryComponentBuilder {
113113
render_fn: self.render_fn,
@@ -175,11 +175,11 @@ impl<__children> SuspenseBoundaryPropsBuilder<((Callback<SuspenseContext, Elemen
175175
impl<__fallback> SuspenseBoundaryPropsBuilder<(__fallback, ())> {
176176
#[allow(clippy::type_complexity)]
177177
#[doc(hidden)]
178-
pub fn children(
178+
pub fn children<__Marker>(
179179
self,
180-
children: Element,
180+
children: impl SuperInto<Element, __Marker>,
181181
) -> SuspenseBoundaryPropsBuilder<(__fallback, (Element,))> {
182-
let children = (children,);
182+
let children = (SuperInto::super_into(children),);
183183
let (fallback, _) = self.fields;
184184
SuspenseBoundaryPropsBuilder {
185185
owner: self.owner,

0 commit comments

Comments
 (0)