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

Unify and change children logic in the html macro #1275

Merged
merged 16 commits into from
Jun 9, 2020

Conversation

siku2
Copy link
Member

@siku2 siku2 commented May 30, 2020

Fixes #1262

What and why?

The easiest way to explain the purpose of these changes is to look at some examples.

Example 1

Let's assume we want to create a List component that wraps the contents in a <ul> tag and also wraps each child in a <li> so that users of this component don't even need to think about it.

The code for such a component looks like this:

#[derive(Clone, Properties)]
struct Props { children: Children }
struct List(Props);
impl Component for List {
    // boilerplate omitted for brevity.
    fn view(&self) -> Html {
        let item_iter = self.0.children.iter().map(|item| html! { <li>{ item }</li> });
        html! {  <ul>{ for item_iter }</ul> }
}

Now let's try to use this component.
If we only use 'literal' html it works as expected:

html! {
  <List>
    <span>Hello</span>
    <span>World</span>
  </List>
};
HTML output

<ul>
  <li><span>Hello</span></li>
  <li><span>World</span></li>
</ul>

But of course in a lot of cases (if not most) the list's content will be dynamically generated. Something like this:

// entries is an iterator yielding Html items.
let entries = vec![
  html! { <span>Hello</span> },
  html! { <span>World</span> },
].into_iter();

html! {
  <List>
    { for entries }
  </List>
};

This doesn't generate the same HTML because the expression block is treated as a single child instead of being 'unfolded' into multiple.

HTML output

<ul>
  <li>
    <span>Hello</span>
    <span>World</span>
  </li>
</ul>

Currently the only way around this issue is to pass the children as a property or to use an undocumented feature that allows us to use a vector in expressions below components.

The primary goal of this PR was to make this particular case work as expected.

Example 2

It is now possible to use a vector in an expression block.

let children = vec![
    html! { <span>{ "Hello" }</span> },
    html! { <span>{ "World" }</span> },
];
html! { <div>{children}</div> };

This should be seen as a better alternative to using .collect::<Html>(). It has more semantic meaning as an actual list of children and is less verbose in a lot of situations.

This is mostly a side effect of the changes but I'm mentioning it because I would like to remove the recommendation for .collect::<Html>(). There may still be a use case for it but it explicitly creates a single vnode thereby circumventing the work done in this PR.
There are certainly times where the 'unwrapping' isn't desired. In those cases I would advocate for the use of fragments:

html! {
  <List>
    <>{ my_elements }</>
  </List>
}

Technical details

Refactor of the different Html tree types

Right now there are 4 tree types: HtmlTree, HtmlRoot, HtmlRootNested, and HtmlTreeNested.
I noticed the following problems:

  • HtmlTreeNested is just HtmlTree but it doesn't wrap the code in ::yew::virtual_dom::VNode::from.
  • HtmlRootNested is just HtmlTreeNested. That's it. There's no difference.
  • HtmlRoot is just HtmlTree but the parse function is extended to handle HtmlIterable and HtmlNode.
  • HtmlTree contains the two variants mentioned above but it will never parse them (only HtmlRoot can create an HtmlTree with those variants).

This PR changes it so that there's only HtmlTree and HtmlRoot.
HtmlTree is basically HtmlTreeNested in that it no longer wraps the code in ::yew::virtual_dom::VNode::from. HtmlTree also no longer contains the HtmlIterable and HtmlNode, both were moved to HtmlRoot.
Speaking of which, HtmlRoot behaves exactly like it did before but it now does a lot of the work that HtmlTree did itself.

Unified node children behaviour.

There are 3 node types that support children: HtmlTag, HtmlList, and HtmlComponent.
All of them currently have their own implementation of the whole children thing and all of them are slightly different.
HtmlList and HtmlComponent are very close. They both use NodeSeq for more flexibility, which is why they already support passing children in a vector. The difference is that HtmlComponent collects HtmlTreeNested nodes while HtmlList uses HtmlTree (note that I'm referring to the old types here, not the refactored ones).
HtmlTag on the other hand is totally different because it doesn't use NodeSeq. Instead it collects HtmlTree and just puts all of them in a vector. Unlike the other two methods, which accept any expression that implements Into<VNode>, here expressions have to resolve to VNode directly.

This PR makes it so all three behave exactly the same. It introduces the new type HtmlChildrenTree which does all of the work.

ChildrenRenderer no longer implements Renderable (breaking change)

The reason for this is the same as for why I think we should discourage .collect::<Html>() - it explicitly creates a single node.

There is one case where this results in a bit more verbosity: when the user actually wants to create a single node. I can only think of one use case for this though and that is for components that want to return their children unchanged. The ContextProvider is one such example.
The code for doing this just looks like this now:

self.children.iter().collect::<Html>()

@jstarry
Copy link
Member

jstarry commented May 31, 2020

This looks promising! @siku2 do you mind writing up a bit more what the problem is and what your solution is? I understand you're making things more consistent (#1262) but I'm not sure what exactly that consistent result is from reading the PR description.

@siku2
Copy link
Member Author

siku2 commented May 31, 2020

@jstarry Yep, that's already on the TODO list 👍
The PR also contains a significant amount of refactoring which is somewhat unrelated to the issue at hand but I felt it was justified to make the code more maintainable.

@jstarry
Copy link
Member

jstarry commented May 31, 2020

Ah whoops, indeed it is! Cool, sounds good 👍

@siku2
Copy link
Member Author

siku2 commented May 31, 2020

@jstarry I just added a section to the PR description where I try to explain the purpose (and desired result) using some examples.

@siku2
Copy link
Member Author

siku2 commented May 31, 2020

Also I hope you don't mind that I removed the checklist from the description. I know it has its purpose but for me it's mostly just in the way...

@siku2
Copy link
Member Author

siku2 commented Jun 1, 2020

@jstarry there's one more case I would like to cover before I'm content with this PR:
Namely forwarding children from one component to another:

// self is a component with a `children` prop.
html! {
    <List>
        { self.props.children }
    </List>
}

Currently this behaves like everything else prior to the changes. That is, the children are rendered into a single VNode before they are passed to the component.
Sadly we can't remove the Renderable trait from ChildrenRenderer because the documentation advocates for the use of .render().

So here's what I propose:

  • Implement IntoIterator on ChildrenRenderer. That way { for self.props.children } works as expected.
  • Implement From<ChildrenRenderer> on NodeSeq to handle { self.props.children }.

If you're willing to accept a breaking change I think we should no longer allow Children to be rendered directly. This way it becomes much harder to accidentally obscure the individual children.

@jstarry
Copy link
Member

jstarry commented Jun 1, 2020

If you're willing to accept a breaking change I think we should no longer allow Children to be rendered directly. This way it becomes much harder to accidentally obscure the individual children.

Willing to accept breaking changes, I trust your judgment!

@siku2
Copy link
Member Author

siku2 commented Jun 1, 2020

I forgot that the children need to be cloned anyway...
Now we're back to this: { self.props.children.clone() }
This isn't really a problem but it's more verbose than I was hoping for.
I can definitely make it work for references like this: { &self.props.children } but I fear at that point we might be hiding the clone complexity from the user.

I could also change the render method to return the inner children vector instead. Doing this would allow all instances where children.render() is called in the html! macro to keep working without having to change anything but it would conflict with the Renderable trait.

So to recap, these are the three options as I see them:

  • Have the user use { children.clone() }
  • Hide the cloning from the user by using a reference { &children }. Using this would throw into question whether this shouldn't also work for Vec.
  • Repurpose the render (or add a new method) to return the inner vector. The benefit here is that ChildrenRenderer doesn't need to be handled separately in NodeSeq.

Right now I'm going with the first approach because it's consistent with all other props which also need to be cloned explicitly. Please let me know if you have a particular preference, @jstarry.

@siku2 siku2 marked this pull request as ready for review June 2, 2020 12:42
@siku2 siku2 changed the title Fix #1262 Unify and change children logic in the html macro Jun 2, 2020
@siku2
Copy link
Member Author

siku2 commented Jun 2, 2020

I guess Travis just doesn't want to cooperate today...

@siku2
Copy link
Member Author

siku2 commented Jun 2, 2020

Oh there's one thing I forgot to ask.
Shouldn't html_nested! allow the same content as html!?
Right now html_nested! uses HtmlTree which doesn't support top-level expressions or iterables.
This has always been a restriction of html_nested! but I think it might have been a mistake given how messy the tree structs were organized.

@jstarry
Copy link
Member

jstarry commented Jun 8, 2020

HtmlRoot is just HtmlTree but the parse function is extended to handle HtmlIterable and HtmlNode.

There is one more difference, HtmlRoot requires a singular root node, whereas HtmlRootNested and HtmlTree allow multiple root nodes

@jstarry
Copy link
Member

jstarry commented Jun 8, 2020

There is one case where this results in a bit more verbosity: when the user actually wants to create a single node.

Why are we stuck with this case?

@jstarry
Copy link
Member

jstarry commented Jun 8, 2020

This has always been a restriction of html_nested! but I think it might have been a mistake given how messy the tree structs were organized.

Yes, due to messiness! I'd be happy if that restriction was removed 👍

@jstarry
Copy link
Member

jstarry commented Jun 8, 2020

By the way, your changes look awesome! Great clean up

jstarry
jstarry previously approved these changes Jun 8, 2020
Copy link
Member

@jstarry jstarry left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Up to you if you'd like to address the html_nested! {} restriction in this PR or in a new one. I think this is great as is

@siku2
Copy link
Member Author

siku2 commented Jun 8, 2020

HtmlRoot is just HtmlTree but the parse function is extended to handle HtmlIterable and HtmlNode.

There is one more difference, HtmlRoot requires a singular root node, whereas HtmlRootNested and HtmlTree allow multiple root nodes

You're right, that is an important detail. This still holds true for the new HtmlRoot but it's something I need to keep in mind when it comes to html_nested!.


There is one case where this results in a bit more verbosity: when the user actually wants to create a single node.

Why are we stuck with this case?

I'm not sure I understand what you mean by this but if you're wondering why one would ever need to do this in the first place then look no further than the ContextProvider component.
Do you think there needs to be a better way to do this?


Up to you if you'd like to address the html_nested! {} restriction in this PR or in a new one. I think this is great as is

I think it makes sense to include this here. It already introduces breaking changes and restructures the whole tree organisation.

@jstarry
Copy link
Member

jstarry commented Jun 8, 2020

Do you think there needs to be a better way to do this?

I was thinking of this

html! { self.props.children.clone() }

I think it makes sense to include this here. It already introduces breaking changes and restructures the whole tree organisation.

Sounds good!

@mergify mergify bot dismissed jstarry’s stale review June 8, 2020 16:27

Pull request has been modified.

@siku2
Copy link
Member Author

siku2 commented Jun 8, 2020

I was thinking of this

html! { self.props.children.clone() }

That would be a lot better, but I can't make it work.
The macro generates the following code:

::yew::virtual_dom::VNode::from({ self.children.clone() })

For this to work we need to implement From<ChildrenRenderer<T>> for VNode but at that point NodeSeq breaks because of the conflicting implementations between From<ChildrenRenderer> and the generic one for From<Into<VNode>>.

The only other way I can think of to make this work would be to treat expressions (HtmlNode) at the HtmlRoot level differently but I don't think that's worth the effort.

We can wrap it in a fragment though:

  html! { <>{ self.props.children.clone() }</> }

I quite like this syntax because it forces the user to consider that they're dealing with a list of children. The generated code is also pretty much equivalent to self.children.clone().into_iter().collect::<Html>().

I think we should go with this one.

@siku2
Copy link
Member Author

siku2 commented Jun 8, 2020

There is one more difference, HtmlRoot requires a singular root node, whereas HtmlRootNested and HtmlTree allow multiple root nodes

I thought about this a bit more and I might have misinterpreted it, but HtmlRootNested (which is now HtmlTree) doesn't support multiple root nodes:

html_nested! {
    <span>{ 1 }</span>
    <span>{ 2 }</span>
};

Generates the error:

error: unexpected token
 |
 |      <span>{ 2 }</span>
 |      ^

It just generates a much more cryptic error message than HtmlRoot because it doesn't have the custom input.is_empty() check.

The question is... should this work? And if so, what should it return?

Perhaps we can come up with a better way to solve the problem html_nested! tries to solve?
Though that's definitely something for another PR.

For now I just changed it so that html! and html_nested are equivalent with the exception that html! always returns a VNode.

@jstarry
Copy link
Member

jstarry commented Jun 9, 2020

The question is... should this work? And if so, what should it return?

Good question, maybe it shouldn't. I'm not sure what it would return 😆

Perhaps we can come up with a better way to solve the problem html_nested! tries to solve?
Though that's definitely something for another PR.

Perhaps, not a pressing issue IMO

For now I just changed it so that html! and html_nested are equivalent with the exception that html! always returns a VNode.

Sounds good!

self.to_vec().into_iter()
pub fn iter<'a>(&'a self) -> impl Iterator<Item = T> + 'a {
// clone each child lazily.
// This way `self.iter().next()` only has to clone a single node.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice improvement 👍

pub struct HtmlRootNested(HtmlTreeNested);
impl Parse for HtmlRootNested {
/// Same as HtmlRoot but always returns a VNode.
pub struct HtmlRootVNode(HtmlRoot);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This naming is a great improvement

@jstarry jstarry merged commit d792e37 into yewstack:master Jun 9, 2020
@siku2 siku2 deleted the fix-1262 branch June 9, 2020 13:27
jstarry added a commit to jstarry/yew that referenced this pull request Jun 14, 2020
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

Successfully merging this pull request may close these issues.

Using the for syntax for rendering iterators hides the elements from the parent component
2 participants