leptos-rs / leptos

Build fast web applications with Rust.

Home Page:https://leptos.dev

Geek Repo:Geek Repo

Github PK Tool:Github PK Tool

Regression in Recursive Component Support After Leptos 7.0: Opaque Type Issues

The-Pac opened this issue · comments

Describe the bug
Since the transition to statically-typed views in Leptos 7.0, recursive components face challenges with type handling:

1- Using Either: Recursive components cannot compile due to recursive opaque type errors.
2- Using .into_any(): This resolves the compilation issue by erasing the type, but it introduces hydration errors, causing the client-rendered DOM to differ from the server-rendered DOM when dynamic data (e.g., signals or resources) is involved.

This creates significant challenges for rendering tree-like recursive structures, such as visualizing hierarchical data.

This is my very first GitHub issue, so apologies if I’ve missed any important details or formatting! Please let me know if you need more information.

Leptos Dependencies

leptos = { version = "0.7.0", features = ["nightly"] }
leptos_router = { version = "0.7.0", features = ["nightly"] }
axum = { version = "0.7", optional = true }
console_error_panic_hook = "0.1"
leptos_axum = { version = "0.7.0", optional = true }
leptos_meta = { version = "0.7.0" }
tokio = { version = "1", features = ["rt-multi-thread"], optional = true }
tower = { version = "0.4", optional = true }
tower-http = { version = "0.5", features = ["fs"], optional = true }
wasm-bindgen = "=0.2.96"
thiserror = "1"
tracing = { version = "0.1", optional = true }
http = "1"
stylance = {version =  "0.5.2",features = ["nightly"] }
getrandom = { version = "0.2.15" ,features = ["js"]}
serde = { version = "1.0.214", features = ["derive"] }
serde_json = {version =  "1.0.133" }
gloo-timers = { version = "0.3", features = ["futures"] }
futures = "0.3"
sqlx = {version =  "0.8.2" ,features = ["sqlite","runtime-async-std"],optional = true}
rand = { version = "0.8.5" ,features = ["nightly"]}

To Reproduce
To Reproduce
Before Leptos 7.0 (Working Version):

In Leptos versions before 7.0, recursive components worked fine without requiring type erasure or additional wrappers. For example, the following recursive component compiles and runs as expected:

use leptos::*;
use std::collections::HashMap;

#[derive(Clone, Default)]
struct CareerNode {
    children: Vec<CareerNode>,
    logo_url: String,
}

#[component]
fn CareerNodeView(node: CareerNode, start_x: f64, start_y: f64, start_angle: f64) -> impl IntoView {
    let children = RwSignal::new(node.children.clone());

    view! {
        <svg>
            <g>
                <Show
                    when=move || !children.get().is_empty()
                    fallback=|| view! {}
                >
                    {move || children.get().iter().enumerate().map(|(i, child)| {
                        let angle = start_angle + i as f64 * 30.0; // Example angle calculation
                        let (x, y) = (start_x + 50.0 * angle.cos(), start_y + 50.0 * angle.sin());
                        view! {
                            <line x1=start_x y1=start_y x2=x y2=y />
                            // Recursive rendering
                            <CareerNodeView node=child.clone() start_x=x start_y=y start_angle=angle />
                        }
                    }).collect_view()}
                </Show>
                <circle cx=start_x cy=start_y r="10" fill="blue" />
            </g>
        </svg>
    }
}

#[component]
fn App() -> impl IntoView {
    let tree = CareerNode {
        children: vec![
            CareerNode {
                children: vec![CareerNode::default()],
                logo_url: "logo1.png".into(),
            },
            CareerNode::default(),
        ],
        logo_url: "root_logo.png".into(),
    };

    view! {
        <CareerNodeView node=tree start_x=100.0 start_y=100.0 start_angle=0.0 />
    }
}

This version works seamlessly and renders a tree-like structure recursively without requiring additional type wrappers.

After Leptos 7.0 (Regression):

In Leptos 7.0, the framework introduced stricter statically-typed views, which breaks the above approach. Recursive components now cause compilation errors due to opaque recursive types. A workaround is to use .into_any() to erase the type information, which allows compilation. However, this approach leads to hydration errors during runtime, as the server-rendered DOM and client-rendered DOM no longer match.

Expected behavior
Recursive components should work seamlessly in Leptos, as they did prior to version 7.0, without requiring manual type erasure through .into_any() or additional wrappers like Either. Ideally, the framework should provide a way to handle recursive views natively while preserving hydration correctness and avoiding runtime errors.

This behavior is crucial for building complex tree-like or graph-like UI structures efficiently, without needing fragile or error-prone workarounds.

Using .into_any() ... introduces hydration errors

this approach leads to hydration errors during runtime, as the server-rendered DOM and client-rendered DOM no longer match.

As I think I said on Discord, this should not be the case; many of our examples use .into_any() in various ways without causing hydration issues, so this surely can't be true as a general statement.

If you could please provide a small, self-contained example in which your use of .into_any() causes a hydration issue, I can try to figure out what's going on.

Otherwise, there's unfortunately no action that can be taken here.

Recursive components should work seamlessly in Leptos, as they did prior to version 7.0, without requiring manual type erasure through .into_any()

Just to add a note explaining the change: In previous versions, every single View was type-erased. This is expensive in terms of runtime speed, memory use, and also binary size, and prevents many compiler optimizations. It is a known and intentional change, not a regression, to no longer implicitly support recursive components by type-erasing everything, but rather to allow you to opt in to type erasure, losing the benefits of all the optimizations you get from a fully-typed view in exchange for being able to have a recursive component.

Using .into_any() ... introduces hydration errors

this approach leads to hydration errors during runtime, as the server-rendered DOM and client-rendered DOM no longer match.

As I think I said on Discord, this should not be the case; many of our examples use .into_any() in various ways without causing hydration issues, so this surely can't be true as a general statement.

If you could please provide a small, self-contained example in which your use of .into_any() causes a hydration issue, I can try to figure out what's going on.

Otherwise, there's unfortunately no action that can be taken here.

While it’s true that .into_any() works correctly in many cases as per the official examples, in my specific case of recursive components, using .into_any() to circumvent the opaque type errors introduced in Leptos 7.0 leads to hydration errors. The client-rendered DOM does not match the server-rendered DOM in this scenario.

Here is a minimal reproducible example demonstrating the issue:

#[component]
fn CareerNodeView(node: CareerNode, start_x: f64, start_y: f64, start_angle: f64) -> impl IntoView {

    let children = RwSignal::new(node.children.clone());

    view! {
        <svg>
            <g>
                <Show when=move || !children.get().is_empty() fallback=|| view! {}>
                    {move ||
                        children.get().into_iter()
                        .enumerate()
                        .map(|(index, (_child_node_id, child_node))| {
                            view! {
                                <CareerNodeView
                                    node=child_node
                                    start_x=0.0
                                    start_y=0.0
                                    start_angle=20.0
                                />
                            }
                        }).collect_view()
                    }
                </Show>
            </g>
        </svg>
    }.into_any()
}
A hydration error occurred while trying to hydrate an element defined at src\components\career_map.rs:211:18.

The framework expected a marker node, but found this instead:  
<svg class="connection_svg-42b5681"> 

The hydration mismatch may have occurred slightly earlier, but this is the first time the framework found a node of an unexpected type. 

Here is a minimal reproducible example demonstrating the issue

Unfortunately, it's not.

When I run your 0.6 example in the above post, with the addition of a single .into_any() at the end of CareerNodeView, there is no hydration error.

When I try to use the example in your latest post, it doesn't compile, with the error

error[E0308]: mismatched types
  --> src/app.rs:57:39
   |
57 |                         .map(|(index, (_child_node_id, child_node))| {
   |                               --------^^^^^^^^^^^^^^^^^^^^^^^^^^^^-
   |                               |       |
   |                               |       expected `CareerNode`, found `(_, _)`
   |                               expected due to this
   |
   = note: expected struct `CareerNode`
               found tuple `(_, _)

When I edit this to (index, child_node) instead, the example compiles and runs; it shows nothing visible, but there are once again no hydration errors.

commented

To ensure we’re on the same page, I’ll post the exact code I’m working with, including all relevant details. Apologies in advance if the code is a bit longer—hopefully, this will make it easier to identify the root of the problem and help me debug further.

If needed, I can also try to reduce the code further to make it more concise for easier reading—it will take me some extra time, but I’m happy to do so if it helps. I’m eager to keep learning and improving with Leptos, and I really appreciate your help!

use std::collections::HashMap;
use std::f64::consts::PI;
use leptos::{component, logging, view, IntoView};
use leptos::html::Div;
use leptos::prelude::{document, request_animation_frame, ClassAttribute, CollectView, Get, NodeRef, NodeRefAttribute, Resource, RwSignal, Set, Show, StyleAttribute, Suspense};
use serde::{Deserialize, Serialize};
use web_sys::{MouseEvent, TouchEvent, WheelEvent};

const RADIUS_DISTANCE_FROM_PARENT: f64 = 500.0;
const ZOOM_FACTOR: f64 = 0.1;
const MIN_SCALE: f64 = 0.5;
const MAX_SCALE: f64 = 5.0;
#[component]
pub fn CareerMap() -> impl IntoView {
    stylance::import_style!(style, "style/career_map.module.scss");
    let career: RwSignal<CareerNodeTree> = RwSignal::new(CareerNodeTree::default());
    let career_nodes : Vec<CareerNode> = vec![ CareerNode {
        id: 0,
        logo_url: "".to_string(),
        title: "Bac".to_string(),
        year: 2019,
        parent_id: None,
        children: Default::default(),
        metadata: None,
    },CareerNode {
        id: 1,
        logo_url: "".to_string(),
        title: "BTS Systèmes Numériques Informatique et Réseaux".to_string(),
        year: 2019,
        parent_id: Some(0),
        children: Default::default(),
        metadata: None,
    },CareerNode {
        id: 2,
        logo_url: "".to_string(),
        title: "Diplôme BTS Systèmes Numériques Informatique et Réseaux".to_string(),
        year: 2020,
        parent_id: Some(1),
        children: Default::default(),
        metadata: None,
    }];
    
    let load_career = Resource::new(
        || (),
        move |_| async move {
            career.set(CareerNodeTree::build_from_flat_data(career_nodes))
        },
    );

    let is_dragging = RwSignal::new(false);
    let drag_start_x = RwSignal::new(0);
    let drag_start_y = RwSignal::new(0);
    let container_left = RwSignal::new(0);
    let container_top = RwSignal::new(0);
    let scale = RwSignal::new(1.0);
    let animation_frame_id = RwSignal::new(-1);
    let is_focus = RwSignal::new(false);

    let map_content_ref:NodeRef<Div> = NodeRef::new();

    let disable_scroll = move || {
        if let Some(document) = document().body() {
            let _ = document.style().set_property("overflow", "hidden");
        }
    };

    let enable_scroll = move || {
        if let Some(document) = document().body() {
            let _ = document.style().set_property("overflow", "auto");
        }
    };

    let update_transform = move || {
        if let Some(container) = map_content_ref.get() {
            let transform_value = format!(
                "translate({}px, {}px) scale({})",
                container_left.get(),
                container_top.get(),
                scale.get()
            );
            container.style(format!("transform : {}", &transform_value));
        }
    };

    let handle_zoom = move |event: WheelEvent| {
        if is_focus.get() {
            event.prevent_default();

            let delta = -event.delta_y() * ZOOM_FACTOR * 0.01;
            let old_scale = scale.get();
            let new_scale = (old_scale + delta).clamp(MIN_SCALE, MAX_SCALE);

            if (new_scale - old_scale).abs() > 0.001 {
                scale.set(new_scale);

                request_animation_frame(move || {
                    update_transform();
                });
            }
        }
    };

    let handle_drag_start = move |event: MouseEvent| {
        event.prevent_default();
        is_dragging.set(true);
        drag_start_x.set(event.client_x() - container_left.get());
        drag_start_y.set(event.client_y() - container_top.get());

        if let Some(container) = map_content_ref.get() {
            let _ = container.style("cursor : grabbing");
        }
    };

    let handle_drag_move = move |event: MouseEvent| {
        if is_dragging.get() {
            let new_left = event.client_x() - drag_start_x.get();
            let new_top = event.client_y() - drag_start_y.get();

            if animation_frame_id.get() == -1 {
                animation_frame_id.set(1);
                request_animation_frame(move || {
                    container_left.set(new_left);
                    container_top.set(new_top);
                    update_transform();
                    animation_frame_id.set(-1);
                });
            }
        }
    };

    let handle_drag_end = move |_| {
        is_dragging.set(false);
        if let Some(container) = map_content_ref.get() {
            let _ = container.style("cursor : grab");
        }
    };

    let handle_touch_start = move |event: TouchEvent| {
        event.prevent_default();
        disable_scroll();

        if let Some(touch) = event.touches().item(0) {
            is_dragging.set(true);
            is_focus.set(true);

            drag_start_x.set(touch.client_x() - container_left.get());
            drag_start_y.set(touch.client_y() - container_top.get());

            if let Some(container) = map_content_ref.get() {
                let _ = container.style("cursor : grabbing");
            }
        }
    };

    let handle_touch_move = move |event: TouchEvent| {
        if is_dragging.get() && event.touches().length() == 1 {
            if let Some(touch) = event.touches().item(0) {
                let new_left = touch.client_x() - drag_start_x.get();
                let new_top = touch.client_y() - drag_start_y.get();

                if animation_frame_id.get() == -1 {
                    animation_frame_id.set(1);
                    request_animation_frame(move || {
                        container_left.set(new_left);
                        container_top.set(new_top);
                        update_transform();
                        animation_frame_id.set(-1);
                    });
                }
            }
        }

        else if event.touches().length() > 1 {
            if let (Some(touch1), Some(touch2)) = (event.touches().item(0), event.touches().item(1)) {
                let distance = ((touch1.client_x() - touch2.client_x()).pow(2) +
                    (touch1.client_y() - touch2.client_y()).pow(2)).isqrt();

            }
        }
    };

    let handle_touch_end = move |event: TouchEvent| {
        event.prevent_default();
        is_dragging.set(false);
        is_focus.set(false);
        enable_scroll();

        if let Some(container) = map_content_ref.get() {
            let _ = container.style("cursor : grab");
        }
    };

    let handle_touch_zoom = move |event: TouchEvent| {
        event.prevent_default();

        if event.touches().length() > 1 {
            if let (Some(touch1), Some(touch2)) = (event.touches().item(0), event.touches().item(1)) {
                let current_distance = ((touch1.client_x() - touch2.client_x()).pow(2) +
                    (touch1.client_y() - touch2.client_y()).pow(2)).isqrt();

                let old_scale = scale.get();
                let new_scale = (old_scale * (current_distance as f64 / 100.0)).clamp(MIN_SCALE, MAX_SCALE);

                if (new_scale - old_scale).abs() > 0.001 {
                    scale.set(new_scale);

                    request_animation_frame(move || {
                        update_transform();
                    });
                }
            }
        }
    };

    view! {
            <div class=style::career_map_container
                on:click=move |_| {
                    is_focus.set(true);
                    disable_scroll();
                }
                on:mouseleave=move |_| {
                    is_focus.set(false);
                    enable_scroll();
                }>
                <div class=style::career_map
                    node_ref=map_content_ref
                    on:mousedown=handle_drag_start
                    on:mousemove=handle_drag_move
                    on:mouseleave=handle_drag_end
                    on:wheel=handle_zoom
                    on:mouseup=handle_drag_end
                    on:touchstart=handle_touch_start
                    on:touchmove=handle_touch_move
                    on:touchend=handle_touch_end
                    on:touchcancel=handle_touch_end
                    on:gesturestart=handle_touch_zoom
                    role="button"
                    tabindex="0">
                        <Suspense fallback=move || view! { <p>"Loading career..."</p> }>
                            {move || {
                                load_career.get().map(|_| {
                                    career.get().roots.iter()
                                    .map(|(id,root)|
                                        view! {
                                            <CareerNodeView node={root.clone()} start_x=1500.0 start_y=200.0 start_angle=90.0/>
                                        }
                                    ).collect_view()
                                })
                            }}
                        </Suspense>
                </div>
            </div>
    }
}

#[component]
fn CareerNodeView(node: CareerNode, start_x: f64, start_y: f64, start_angle: f64) -> impl IntoView {
    stylance::import_style!(style, "style/career_card.module.scss");

    let children = RwSignal::new(node.children.clone());

    let num_children = children.get().len();
    let angle_delta = if num_children > 1 {
        140.0 / (num_children - 1) as f64
    } else {
        70.0
    };

    view! {
        <svg class=style::connection_svg>
            <g>
                <Show when=move || !children.get().is_empty() fallback=|| view! {}>
                    {move ||
                        children.get().into_iter()
                        .enumerate()
                        .map(|(index, (_child_node_id, child_node))| {
                            let child_angle = if num_children > 1 {
                                start_angle - 70.0 + index as f64 * angle_delta
                            } else {
                                start_angle
                            };
                            let (child_x, child_y) = calculate_coordinates(
                                RADIUS_DISTANCE_FROM_PARENT,
                                child_angle
                            );

                            let absolute_child_x = start_x + child_x;
                            let absolute_child_y = start_y + child_y;

                            view! {
                                <line
                                    x1=start_x
                                    y1=start_y
                                    x2=absolute_child_x
                                    y2=absolute_child_y
                                    stroke="rgba(0,0,0,0.3)"
                                    stroke-width="2"
                                    stroke-dasharray="5,5"
                                    pointer-events="none"
                                />
                                <CareerNodeView
                                    node=child_node
                                    start_x=absolute_child_x
                                    start_y=absolute_child_y
                                    start_angle=child_angle
                                />
                            }
                        }).collect_view()
                    }
                </Show>
                <g
                    class=style::career_card
                    style:transform=format!("translate({}px, {}px)", start_x as i32, start_y as i32)
                >
                    <circle
                        r="50"
                        fill="white"
                        stroke="black"
                        stroke-width="2"
                    />
                    <image
                        href=node.logo_url.clone()
                        x="-25"
                        y="-25"
                        width="50"
                        height="50"
                    />
                </g>
            </g>
        </svg>
    }.into_any()
}

fn calculate_coordinates(rayon: f64, angle_deg: f64) -> (f64, f64) {
    let angle_rad = (angle_deg * PI) / 180.0;

    let x = rayon * angle_rad.cos();
    let y = rayon * angle_rad.sin();

    (x, y)
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct CareerNode {
    pub id: i32,
    pub logo_url: String,
    pub title: String,
    pub year: i32,
    pub parent_id: Option<i32>,
    pub children: HashMap<i32, CareerNode>,
    pub metadata: Option<HashMap<String, String>>,
}

#[derive(Clone, Deserialize, Serialize, Default)]
pub struct CareerNodeTree {
    pub roots: HashMap<i32, CareerNode>,
}

impl CareerNodeTree {
    pub fn build_from_flat_data(nodes: Vec<CareerNode>) -> Self {
        let mut node_map: HashMap<i32, CareerNode> = nodes
            .into_iter()
            .map(|node| (node.id, node))
            .collect();

        let root_id = node_map
            .values()
            .find(|node| node.parent_id.is_none())
            .expect("Aucun root node trouvé. Un nœud sans parent_id est nécessaire.")
            .id;

        let mut root = node_map
            .remove(&root_id)
            .expect("Impossible de trouver le root après extraction.");

        let mut unprocessed = Vec::new();
        while !node_map.is_empty() {
            for (id, node) in node_map.drain() {
                if let Some(parent_id) = node.parent_id {
                    if let Some(parent_node) = Self::find_node_mut(&mut root, parent_id) {
                        parent_node.children.insert(node.id, node);
                    } else {
                        unprocessed.push((id, node));
                    }
                }
            }

            for (id, node) in unprocessed.drain(..) {
                node_map.insert(id, node);
            }
        }

        CareerNodeTree {
            roots: HashMap::from([(root_id, root)]),
        }
    }

    fn find_node_mut(node: &mut CareerNode, id: i32) -> Option<&mut CareerNode> {
        if node.id == id {
            return Some(node);
        }
        for child in node.children.values_mut() {
            if let Some(found) = Self::find_node_mut(child, id) {
                return Some(found);
            }
        }
        None
    }
}

The issue you are experiencing does not seem to have anything to do with .into_any().

It seems to have to do with the way that you are using Resource, which does not resemble how it is intended to be used.

You are using a Resource that returns no value (()), but mutates a signal in its async block. This just does not work.

Try this instead:

let load_career = Resource::new(|| (), {
    let career_nodes = career_nodes.clone();
    move |_| {
        let career_nodes = career_nodes.clone();
        async move {
            CareerNodeTree::build_from_flat_data(career_nodes)
        }
    }
});

// ...
<Suspense fallback=move || view! { <p>"Loading career..."</p> }>
    {move || {
        load_career.get().map(|career| {
            career.roots.iter()
            .map(|(id,root)|
                view! {
                    <CareerNodeView node={root.clone()} start_x=1500.0 start_y=200.0 start_angle=90.0/>
                }
            ).collect_view()
        })
    }}
</Suspense>

That has no hydration error.

If you have questions about why mutating a signal inside the async block of a Resource is a bad idea, the answer is that Resource loads its data on the server, and serializes it to the client, and therefore the async block doesn't run on the client: it just deserializes the data. However, your load_career resource serializes no data to the client, but sets a signal on the server that it doesn't set on the client, because it's just trying to deserialize the value that was sent.

Feel free to ask additional help questions or Discord or consult the async sections of the book; I do not think there is an issue here. If you run into a future issue that you think is related to .into_any(), please open a new issue with a minimal reproduction.

commented
{
let career: RwSignal<CareerNodeTree> = RwSignal::new(CareerNodeTree::default());

    let load_career = Resource::new(
        || (),
        move |_| async move {
            match load_career().await {
                Ok(career_tree) => career.set(career_tree),
                Err(err) => logging::error!("Career loading error: {:?}", err),
            }
        },
    );
    
} 
#[server(LoadCareer)]
pub async fn load_career() -> Result<CareerNodeTree, ServerFnError> {
    let career_nodes: Vec<CareerNode> = get_career_nodes().await?;

    Ok(CareerNodeTree::build_from_flat_data(career_nodes))
}    


#[server]
pub async fn get_career_nodes() -> Result<Vec<CareerNode>, ServerFnError> {
    let mut connection = crate::libs::database::ssr::db().await?;

    let career_nodes = sqlx::query_as::<_, CareerNode>("SELECT * FROM careers")
        .fetch_all(&mut connection)
        .await?;

    Ok(career_nodes)
}
commented

Thank you for your detailed response and explanation regarding Resource. You’re absolutely right that the simplified example I provided used signal mutation as a placeholder to focus on reproducing the issue. In my actual project, I do use a proper #[server] function to fetch and deserialize data, which avoids the pitfalls you mentioned about signal mutation during server-side rendering.

That said, I am still encountering the hydration error when using .into_any() in my actual implementation, even when using the #[server] function. This issue is preventing me from making progress, as I cannot figure out what’s causing the mismatch between the server-rendered and client-rendered DOM.

my actual project... avoids the pitfalls you mentioned about signal mutation during server-side rendering.

Is your actual project the additional sample you posted above after I replied?

Because if so, it is still doing the exact same thing: mutating a signal in the async block of the resource:

{
let career: RwSignal<CareerNodeTree> = RwSignal::new(CareerNodeTree::default());

    let load_career = Resource::new(
        || (),
        move |_| async move {
            match load_career().await {
                Ok(career_tree) => career.set(career_tree), // THIS IS THE ISSUE
                Err(err) => logging::error!("Career loading error: {:?}", err),
            }
        },
    );
commented
let load_career_ressource = OnceResource::new(load_career());


<Suspense fallback=move || view! { <p>"Loading career..."</p> }>
    { move || {
        load_career_ressource.get().map(|career| {
            match career {
                 Ok(career_data) => {
                    Either::Left(
                        career_data.roots.iter()
                            .map(|(_id, root)| {
                                view! {
                                    <CareerNodeView node={root.clone()} start_x=1500.0 start_y=200.0 start_angle=90.0/>
                                }
                            })
                            .collect_view(),
                    )
                }
                Err(_) => {
                    Either::Right(
                        view! {
                            <p>"Failed to get data."</p>
                        },
                    )
                }
            }
        })
    }}
</Suspense>

Thank you for pointing that out. After your feedback, I modified my code to avoid mutating a signal in the async block of the Resource. However, even after making those adjustments, the hydration error persists—it has just shifted to a different place in the application.

I’m trying to figure out if I am still doing something wrong in my approach or if there is something else causing this issue. I would appreciate any guidance or insight you can provide, as I am eager to continue using Leptos and better understand the framework.


The hydration mismatch may have occurred slightly earlier, but this is the first time the framework found a node of an unexpected type.```