Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Extending class Attribute Support #1492

Open
power-unsealed opened this issue Aug 3, 2023 · 2 comments
Open

Extending class Attribute Support #1492

power-unsealed opened this issue Aug 3, 2023 · 2 comments
Labels
enhancement New feature or request

Comments

@power-unsealed
Copy link

Is your feature request related to a problem? Please describe.
When using my own custom components, I sometimes have to attach an additional CSS class to them, that applies only to individual instances. Modifying the width would be an example application.

Describe the solution you'd like

  1. Some sort of native class attribute support on custom components, so that classes could be attached to custom components like they were native HTML tags
  2. Besides supporting the tuple syntax for classes, being able to pass an Option would be nice, too. The exact type definition would need some discussion, though, since you cannot do Option<impl Into<Cow<...>>> without trait objects (?)

Describe alternatives you've considered
For now, I integrated an additional class prop that takes an Option<&'static str> into my custom components. I implemented a trait method on HtmlElement taking that optional class and attaching it to the element, since I found no way to do that directly in the view! macro. But integrating this for every component is some overhead. This also does not support the syntactic sugar the view! macro usually provides for working with classes.
Another alternative would be to add an explicit width prop (to stay with the example from above), but that would be quite limiting. However, maybe it is intended like that?

@gbj gbj added the enhancement New feature or request label Aug 3, 2023
@gbj
Copy link
Collaborator

gbj commented Aug 3, 2023

I want to say, first of all: DX-wise, this would be a clear win. If the only goal were to make the framework as flexible as possible and easy for developers to use as possible, I'd add this in a heartbeat.

I also want to explain why it isn't already available, and suggest some possible work-arounds.

Background: Server-Rendering Optimizations

Imagine a component like this:

Your Code

#[component]
fn ExampleComponent(label: String, first_item: String) -> impl IntoView {
    view! {
        <div class="some-class">
            <strong>{label}</strong>
            <ul>
                <li>{first_item}</li>
                <li>"B"</li>
                <li>"C"</li>
            </ul>
        </div>
        <span>"Hi!"</span>
    }
}

Macro Output (CSR/Hydrate)
That view macro gets compiled into something that's essentially like this, if I clean it up a bit.

use ::leptos::leptos_dom::html::*;

fn ExampleComponent(label: String, first_item: String) -> impl IntoView {
    Fragment::new([
        div()
            .attr("class", "some-class")
            .child(strong().child(label))
            .child(
                ul().child(li().child(first_item))
                    .child(li().child("B"))
                    .child(li().child("C")),
            ),
        span().child("Hi!"),
    ])
}

That looks perfectly reasonable. It's not the fastest thing in the browser (we have a template! macro that's actually more optimized for specific cases) but it's plenty fast.

On the server, though, it can be improved by a lot. Almost all of the view macro is completely static. There are only two "holes" (label and first_item) which will ever change depending on the component arguments.

What if we could just do...

Optimized SSR Output

fn ExampleComponent(label: String, first_item: String) -> impl IntoView {
    HtmlElement::from_html(format!(
        r#"<div class="some-class">
            <strong>{}</strong>
            <ul>
                <li>{}</li>
                <li>B</li>
                <li>C</li>
            </ul>
        </div>
        <span>Hi</span>"#,
        label,
        first_item
    ))
}

Wouldn't that be faster?

Yes. And it is. In fact it's much, much faster. Obviously depending on the circumstances how much faster varies, but for a typical page the benchmarks I've done suggest it's something like a 60-70% improvement, i.e., it makes your server rendering 2-3x faster. And so it's exactly what we do. Rather than generating a tree of a bunch of virtualized DOM nodes/HTML elements, Leptos rendering on the server generates, at compile time, a template HTML string, literally using format!() and inserting dynamic content into the "holes."

The Problem

I hope that wasn't too long a digression, and I hope it makes the problem more apparent. For the purposes of optimization the view macro, on the server, generates essentially a black box. If you're using ssr and the view macro you don't get a tree of virtual elements that you can just add a class onto before rendering them all down to an HTML string, because most of this intermediate tree is never constructed, which has huge memory and CPU advantages.

However it means that it becomes harder to do nice things like this request. If I want to use

<ExampleComponent class:hidden=true/>

during server rendering, how should it work? Do we need to start parsing HTML to find each top-level element and apply the class? Should we parse that element to find out if it has a class attribute? Should we parse that class attribute to see if it already has hidden and, if so, remove it?

I'm not saying this sarcastically or as if it's a bad idea to do that. Maybe it's actually a good idea, I don't know! But it's definitely harder than it would be without these optimizations.

There's already a parallel case

<ExampleComponent on:click=/* ... */ />

This really does iterate over the top-level HTML elements of ExampleComponent, adding the event listener to each one of them.

The reason we can do this with on: listeners is precisely because they aren't server rendered; they only exist on the client, so we actually can just iterate over the elements with no real cost.

Possible Paths Forward

  1. Add class: support to components Reading through what I wrote, this doesn't seem impossible. Because it would be opt-in, it wouldn't necessarily even be a big de-opt... Basically if you're in SSR and the framework sees class: on a component, it could actually go do that parsing. Possible but hard additional maintenance burden.
  2. Add class: but only if you used the builder. Of course you can manually use the builder rather than the view macro in your components, in which case you lose the optimization anyway. We could allow class: but only for the builder. This seems both confusing for users and like a missed opportunity. Probably a bad idea.
  3. Provide your own props (class or width or whatever) If you take Option<AttributeValue> you should even be able to pass it through pretty directly to the element you want. This is what I currently do in the other crates (leptos_router and leptos_meta). Status quo; inconvenient for component developer.

So yeah I'd say Option 3 is the best bet for now but maybe this would be possible in the future, at some runtime cost.

@power-unsealed
Copy link
Author

power-unsealed commented Aug 4, 2023

First of all, thank you for taking your valuable time and giving me such an extensive answer.

I hope, I have been able to follow along enough to understand the problem. I fully agree that taking 30% of the possible performance, just to improve DX a little bit, is no acceptable solution. I have some ideas for Option 1 with probably less performance impact, but I don't know if any of them are acceptable in terms of complexity and maintainability.

Possible Solutions for Option 1

A) My first attempt would have been to additionally interpolate the classes. The view macro would generate the template string for the component, while excluding the classes and collecting them into a container to be passed along to the parent. The parent could then apply all class manipulations and insert the resulting set of classes into the string. This would, however, require a class attribute to always be added to the template.

For a single top-level element, that might look something like this.

fn ExampleComponent(label: String, first_item: String) -> impl IntoView {
    let classes = vec![ "some-class" ];
    HtmlElement::from_html(format!(
        r#"<div class="{}">
            <strong>{}</strong>
            <ul>
                <li>{}</li>
                <li>B</li>
                <li>C</li>
            </ul>
        </div>"#,
        classes,
        label,
        first_item
    ))
}

That might work, but I don't know how much it would hurt rendering performance. It might still be acceptable. However, if this would be implemented, a few months from now, someone might come along and kindly ask the same for the style attribute or whatever.

B) At some point, then, it might just as well be a solution to return the top-level element(s) as virtualized DOM nodes, with just their children being rendered as template string (passed along as inner_html). The parent could then take care of rendering the top-level element(s) and interpolating all values. That would be a hybrid approach, but I expect quite a significant performance degradation.

C) We could still do a hybrid-hybrid approach. A component could offer two functions for rendering when using SSR: one function (F1) generating the complete template string as usual for situations, where this is sufficient. The second function (F2) would return the result of idea A or B.

We would "just" need some way to distinguish within the parent's view macro, which of these functions to call. For idea A that would be simple: just call F2, if a class attribute was used, otherwise stay with F1. Recognizing where idea B would be applicable might, however, be more difficult or impossible.

We would need to know, whether some of the attributes given to the component instance are HTML-native and would thus require calling F2. There is (I guess) no way to know inside a proc-macro, which type of DOM node (with it's native attributes) the component returns. We could establish some convention, that such HTML-native attributes on components would need to be prefixed somehow (something like raw:class="my-width", inline, or native). Whenever a raw attribute was used, we would call F2, otherwise we could stay with the optimized F1.

D) The last idea would be to allow native attributes on components only for client-side rendering. That, however, might be to inconsistent for the user, I don't know.

These are my ideas. 😅

I hope, my explanations were (kind of) understandable. If none of these options seem acceptable for you and this thread is a dead-end, feel free to close the issue. 🙂

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
enhancement New feature or request
Projects
None yet
Development

No branches or pull requests

2 participants