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

Add syntax to bind to properties instead of attributes #2819

Merged
merged 18 commits into from Aug 14, 2022

Conversation

hamza1311
Copy link
Member

@hamza1311 hamza1311 commented Aug 8, 2022

Description

Fixes #1322
Fixes #2530

Properties are set by using js_sys::Reflect. While it doesn't give much benifit today (they're still strings), it opens the door for more opportunities since we can now define specific types for properties. This will help with statically typed DOM, whenever that becomes a thing.
This PR also introduces new syntax, ~key="value" which can be used to forcefully set as properties.

class is set as always attribute, since it is not a property. Classes are stored in classList in DOM. Since they're always strings, it's not worth the extra JS calls to add to classList.

Checklist

  • I have reviewed my own code
  • I have added tests

@hamza1311 hamza1311 added breaking change A-yew Area: The main yew crate labels Aug 8, 2022
@hamza1311 hamza1311 added this to the v0.20 milestone Aug 8, 2022
github-actions[bot]
github-actions bot previously approved these changes Aug 8, 2022
@github-actions
Copy link

github-actions bot commented Aug 8, 2022

Visit the preview URL for this PR (updated for commit dfd2082):

https://yew-rs-api--pr2819-properties-not-attri-4sjqxi5x.web.app

(expires Sun, 21 Aug 2022 16:07:06 GMT)

🔥 via Firebase Hosting GitHub Action 🌎

github-actions[bot]
github-actions bot previously approved these changes Aug 8, 2022
@github-actions
Copy link

github-actions bot commented Aug 8, 2022

Benchmark - SSR

Yew Master

Benchmark Round Min (ms) Max (ms) Mean (ms) Standard Deviation
Baseline 10 456.439 497.363 478.151 13.213
Hello World 10 1275.314 1379.824 1337.679 30.825
Function Router 10 4461.417 4867.809 4705.128 126.432
Concurrent Task 10 1013.351 1015.421 1014.111 0.680

Pull Request

Benchmark Round Min (ms) Max (ms) Mean (ms) Standard Deviation
Baseline 10 367.121 448.600 395.752 24.736
Hello World 10 1273.120 1398.973 1310.010 36.039
Function Router 10 4426.651 4728.682 4563.521 101.262
Concurrent Task 10 1012.763 1018.593 1014.791 1.600

@github-actions
Copy link

github-actions bot commented Aug 8, 2022

Size Comparison

examples master (KB) pull request (KB) diff (KB) diff (%)
boids 168.591 169.331 +0.740 +0.439%
contexts 106.704 107.353 +0.648 +0.608%
counter 84.323 84.876 +0.553 +0.655%
counter_functional 84.963 85.499 +0.536 +0.631%
dyn_create_destroy_apps 87.275 87.830 +0.555 +0.636%
file_upload 100.852 101.397 +0.546 +0.541%
function_memory_game 162.247 162.906 +0.659 +0.406%
function_router 343.427 345.812 +2.386 +0.695%
function_todomvc 157.192 157.918 +0.726 +0.462%
futures 221.177 221.962 +0.785 +0.355%
game_of_life 104.537 105.197 +0.660 +0.632%
immutable 180.215 180.711 +0.496 +0.275%
inner_html 81.438 81.975 +0.536 +0.658%
js_callback 110.019 110.691 +0.673 +0.612%
keyed_list 192.825 193.475 +0.649 +0.337%
mount_point 84.122 84.661 +0.539 +0.641%
nested_list 111.504 112.271 +0.767 +0.688%
node_refs 91.232 91.815 +0.583 +0.639%
password_strength 1547.433 1548.208 +0.775 +0.050%
portals 94.743 95.396 +0.652 +0.689%
router 312.878 315.271 +2.394 +0.765%
simple_ssr 150.679 151.340 +0.661 +0.439%
ssr_router 388.935 391.330 +2.396 +0.616%
suspense 107.702 108.390 +0.688 +0.638%
timer 87.096 87.669 +0.573 +0.658%
todomvc 138.570 139.265 +0.694 +0.501%
two_apps 84.983 85.521 +0.537 +0.632%
web_worker_fib 149.949 150.504 +0.555 +0.370%
webgl 85.117 85.655 +0.538 +0.632%

✅ None of the examples has changed their size significantly.

github-actions[bot]
github-actions bot previously approved these changes Aug 8, 2022
github-actions[bot]
github-actions bot previously approved these changes Aug 8, 2022
github-actions[bot]
github-actions bot previously approved these changes Aug 8, 2022
github-actions[bot]
github-actions bot previously approved these changes Aug 8, 2022
github-actions[bot]
github-actions bot previously approved these changes Aug 8, 2022
packages/yew/src/virtual_dom/vtag.rs Outdated Show resolved Hide resolved
pub label: HtmlDashedName,
/// Punctuation between `label` and `value`.
pub value: Expr,
}
impl Parse for Prop {
fn parse(input: ParseStream) -> syn::Result<Self> {
let at = input.parse::<Token![@]>().map(|_| true).unwrap_or(false);
Copy link
Member

Choose a reason for hiding this comment

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

Let's discuss some syntax and why I think using @ is not not intuitive.

  • @{} is used for dynamic tags already. We're currently not supporting dynamic attribute names, but if we were to, I'd reuse the syntax there. Since this feature does something else, I find it confusing.
  • vue syntax calls this style of modifying what a prop assignment means a "directive". There's a directive for callbacks, i.e. @click = v-on:click = a callback on the click event. And there's a directive for attribute binding, :href = v-bind:href = sets the attribute of the element.
  • Rust has alternative syntax for something akin to directives: Attributes

My preferred solution: use Token![:], i.e. :attr-name. An eventual dynamic attribute name would look like :@{expr}={value}. When we want to support further directives, we could use #[attr] attr-name={value} and #[on] event={handler} (not so sure about a short-hand form for event handlers, $click?).

Can we also support a syntax such as .prop-name (or #[prop] prop-name) to explicitly assign to a property? This is important for the complication with the property reflecting an attribute not being always named the same. See below on the comment about "class".

Copy link
Member Author

Choose a reason for hiding this comment

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

packages/yew-macro/src/props/prop.rs Outdated Show resolved Hide resolved
ApplyAttributeAs::Property => {
match key {
// need to be attributes because, otherwise query selectors fail
"class" => el.set_attribute(key, value).expect("invalid attribute key"),
Copy link
Member

Choose a reason for hiding this comment

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

Instead of special casing this at runtime, would it be better to do this handling in the macro?

To explain, HTML attributes are reflected by properties on the HtmlElement. The complication is that this mapping changes some names.

  • :class is reflected by .className. Imo, html! { <div class="foo" /> } could be rewritten - handling this mismatch - into html! { <div .className="foo /> }.
  • some other attributes that change their name while reflected as a property

The approach would be that the syntax name would rewrite some several well-known attribute names into their proper property names, and assign those at runtime instead. .name would bypass this rewriting and just assign to the property as named. At least, afaik, the list of attributes that get reflected by the samely named property is small. I can't find a good list though to link here.

Another special case (why is this so messy?... I hate browsers) is the value attribute. This is reflected by the defaultValue property because, well, it only assigns the default value of an <input>. The developer expection (and the current implementation, I believe) is that it dynamically assigns the value property instead (and bypasses the value attribute completely). Similarly with :selected is reflected as .defaultSelected but I think dev expectation of dynamically binding to .selected is preferred. See also #2530

Copy link
Member Author

Choose a reason for hiding this comment

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

I removed the special casing entirely as the properties are no longer set by default. They are explicitly opt-in so it's user's responsibility to set the correct one.

@futursolo
Copy link
Member

futursolo commented Aug 11, 2022

I am not sure whether making properties default is better than existing behaviour.

Most issues mentioned in this pull request can be solved without making properties default.

  • Statically typed dom can also be enforced with Rust regardless of whether attribute is default or property is default.
    It is unlikely that we will be able to apply TypeScript-like interface SomeOptions {} for JsValue.

  • Custom Elements can use both Attributes and Properties.
    If one wants to bind to properties, we can introduce a way to bind to properties explicitly.
    (@some_prop to .someProp?)

  • To fix select element, the value can be handled by adding a variant to VTagInner, like VTagInner::Input and apply values with set_value.

Other things to consider:

  • SSR will always render to attributes and cannot render anything other than AttrValue / bool.
    This might cause inconsistency between SSR created content and CSR created content.
    (Setting className as class works in CSR but not SSR.)

  • There might be more props need to default to attributes.
    (e.g.: data-*, aria-*, for (<label for="some-input"></label>))

@hamza1311
Copy link
Member Author

I am not sure whether making properties default is better than existing behaviour.

You're right, it's better to keep the option to the default as attributes and give the option to set as property

I made it use ~ as the token as : causes errors with parsing. Available symbols are (space separated): !, $, \, ~ and "`" (GH won't let me put that as inline code. Everything else is either already taken by Rust or has some other meaning:

  • @ => used for dynamic tags
  • # => id selector
  • % => mod symbol
  • & => borrowing op
  • * => deref operator
  • . => class selector
  • : => causes parser error (thing="" :other="" results in parsing errors because the all the tokens after thing are considered part of assignment expression)

github-actions[bot]
github-actions bot previously approved these changes Aug 14, 2022
github-actions[bot]
github-actions bot previously approved these changes Aug 14, 2022
github-actions[bot]
github-actions bot previously approved these changes Aug 14, 2022
Copy link
Member

@futursolo futursolo left a comment

Choose a reason for hiding this comment

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

Mostly looks good to me. I liked the syntax of ~ over :.

Comments are optional to fix.

.expect("could not remove attribute"),
ApplyAttributeAs::Property => {
let key = JsValue::from_str(key);
js_sys::Reflect::set(el.as_ref(), &key, &JsValue::UNDEFINED)
Copy link
Member

Choose a reason for hiding this comment

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

Sometimes setters may not be expecting undefined and this can cause panic.

I am not sure what is the best solution here.

Although, there is an option to leave the last set value as-is and do nothing to avoid setting something unintended, it's equally unclean to leave a residual value.

I don't think either set to undefined or leave as-is is semantically better than the other option as JavaScript only allows properties to listen to get and set but not delete event.

So I am fine with it being handled either way, just bringing this up as an alternative option.

Copy link
Member Author

Choose a reason for hiding this comment

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

It already used to panic when attribute couldn't be set so I think consistency here is fine. If it is to be changed, it should be done for both properties and attributes

/// A collection of attributes for an element
#[derive(PartialEq, Eq, Clone, Debug)]
pub enum Attributes {
/// Static list of attributes.
///
/// Allows optimizing comparison to a simple pointer equality check and reducing allocations,
/// if the attributes do not change on a node.
Static(&'static [[&'static str; 2]]),
Static(&'static [(&'static str, &'static str, ApplyAttributeAs)]),
Copy link
Member

Choose a reason for hiding this comment

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

Instead of making properties a variant here, it might be cleaner to make a separate properties field on VTag.
So we do not have to check for every entry whether it is a property or an attribute during the diffing process and a property and an attribute with the same name can co-exist.

Copy link
Member Author

Choose a reason for hiding this comment

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

That's for a different PR, imo. We can break the data into separate struct. &'static str and AttrValue::Static should be the same (after - optimizations) so that can be used. It should also reduce the code complexity

async fn macro_syntax_works() {
#[function_component]
fn Comp() -> Html {
html! { <a href="https://example.com/" ~alt="abc" /> }
Copy link
Member

Choose a reason for hiding this comment

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

Suggested change
html! { <a href="https://example.com/" ~alt="abc" /> }
html! { <a href="https://example.com/" alt~="abc" /> }

Maybe it's cleaner to denote it on the operator than in front of the name?
Although I am fine with it at the beginning as well.

Copy link
Member

Choose a reason for hiding this comment

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

I believe in front of the name is better: ~ arguably modifies the name, not the assignment and ~= is confusing since it can be read as alt(~=) instead of (alt~)= and the reader is left wondering what the ~= operator does, is it something like +=?

Copy link
Member Author

Choose a reason for hiding this comment

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

That also causes parsing problems since ident*=expr is valid Rust but ~ can't be used there so parser doesn't know what to do

..
}| {
(
label.to_lit_str(),
Copy link
Member

Choose a reason for hiding this comment

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

For properties, maybe we should apply a snake_case to camelCase conversion here like web_sys.
We only need to do this with html! macro as at runtime property names are set as string.

// snake case as we assume a it's like a field in Properties for an html element.
html! { <custom-element ~some_prop={"value"} /> };

// camel case as it's a string.
el.add_property("someProp", "value");

Copy link
Member Author

Choose a reason for hiding this comment

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

I'm not sure if this is a good idea. We don't convert snake_case to kebab-case for attributes so this will be just another differentiator between the two. We can add it for both though, but that is for a future PR

/// Set the given key as property on the element
///
/// [`js_sys::Reflect`] is used for setting properties.
pub fn add_property(&mut self, key: &'static str, value: impl Into<AttrValue>) {
Copy link
Member

Choose a reason for hiding this comment

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

In the future, we should allow properties to be anything Into<JsValue>, but Into<AttrValue> is fine for this pull request.

Copy link
Member Author

Choose a reason for hiding this comment

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

This sounds like opening gates for a disaster. onclick and ~onclick have different meanings then. The former is handled through Yew while the other would completely bypass Yew (and all the bubbling, etc) and apply directly to the element, all without use of noderef. Unless someone comes with a genuine need for this, I wouldn't want to add this

@hamza1311
Copy link
Member Author

@WorldSEnder can you leave review here? It's blocked because of your review

@hamza1311 hamza1311 enabled auto-merge (squash) August 14, 2022 18:04
@hamza1311 hamza1311 merged commit 4c35f95 into yewstack:master Aug 14, 2022
@WorldSEnder WorldSEnder changed the title Bind to properties instead of attributes by default Add syntax to bind to properties instead of attributes Aug 14, 2022
@WorldSEnder WorldSEnder mentioned this pull request Sep 23, 2022
29 tasks
@huntc
Copy link

huntc commented Sep 24, 2022

Why did we choose to have the special syntax to demark explicitly setting a property, and not the attribute instead (as per #1322)? Or have I misread?

UPDATE: I see some rationale to retain the default as attr given SSR. I think I’d personally rather SSR require explicit attr setting and error out if a default property was set. Perhaps even better, introduce a new dom! macro to distinguish it from html!

To me, the html! macro is more about manipulating the dom as in manipulating properties… The distinction between attributes and properties is overall quite confusing, and surprising to the Yew developer, and needing to explicitly address a property perhaps adds to that given the prevalence of Props elsewhere.

@WorldSEnder
Copy link
Member

WorldSEnder commented Sep 25, 2022

Why did we choose to have the special syntax to denote explicitly setting a property, and not the attribute instead (as per #1322)? Or have I misread?

For one, to conserve a bit of backwards compatibility. For example type-safety dictates that attributes must be stringly typed (AttrValue), but properties could (should?) allow any JsValue. So it's easier to introduce or change this part only for the new syntax later.

For two, because the current syntax gives us wiggle room. The current syntax without any qualifiers reserves to do secret tricks that match and mix attributes and properties and try to do "what you meant".

And third, because properties and attributes are just named differently. When typing out what looks like html, dashed casing like aria-autocomplete is used and it's not so simple to convert to the property name even if the IDL property is supported in the first place. Even if such a name transformation would happen at compile time, it's a lot of catch-up to play with new browser and accessibility attributes, for little gain. I think the default assumption is still that html! { } refers to attributes, for most users, since it looks like HTML, after all.

EDIT: I would love to learn more about the interaction with custom elements. Do you happen to use them and can you share if the default should be different there? I think a lot of the arguments above apply to default elements shipped by the browser, but are they applicable to custom elements?

@huntc
Copy link

huntc commented Sep 25, 2022

Why did we choose to have the special syntax to denote explicitly setting a property, and not the attribute instead (as per #1322)? Or have I misread?

For one, to conserve a bit of backwards compatibility. For example type-safety dictates that attributes must be stringly typed (AttrValue), but properties could (should?) allow any JsValue. So it's easier to introduce or change this part only for the new syntax later.

For two, because the current syntax gives us wiggle room. The current syntax without any qualifiers reserves to do secret tricks that match and mix attributes and properties and try to do "what you meant".

And third, because properties and attributes are just named differently. When typing out what looks like html, dashed casing like aria-autocomplete is used and it's not so simple to convert to the property name even if the IDL property is supported in the first place. Even if such a name transformation would happen at compile time, it's a lot of catch-up to play with new browser and accessibility attributes, for little gain. I think the default assumption is still that html! { } refers to attributes, for most users, since it looks like HTML, after all.

Thanks for the explanations. Perhaps the existing approach could be preserved but a new, complimentary, dom-focused macro introduced for properties. My main concern is the developer’s mental consideration of whether specifying a property or attribute is required. My assertion is that most devs don’t understand the difference between attributes and properties. Are these concerns shared?

EDIT: I would love to learn more about the interaction with custom elements. Do you happen to use them and can you share if the default should be different there? I think a lot of the arguments above apply to default elements shipped by the browser, but are they applicable to custom elements?

Custom elements as per Components? They are definitely dom-focused and more consistent with what I’m thinking of with a separate dom-focused macro.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
A-yew Area: The main yew crate breaking change
Projects
None yet
Development

Successfully merging this pull request may close these issues.

Select element last element cant be set programatically Bind to properties instead of attributes by default
4 participants