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

An ability to specify default values for @named arguments #479

Closed
lolmaus opened this issue Apr 19, 2019 · 50 comments
Closed

An ability to specify default values for @named arguments #479

lolmaus opened this issue Apr 19, 2019 · 50 comments

Comments

@lolmaus
Copy link

lolmaus commented Apr 19, 2019

Previously, I was able to provide default values to arguments like this:

component.js:

Component.extend({
  foo: 'bar'
})

template.hbs:

{{foo}}

result:

bar

It was a simple, efficient and very declarative way of specifying default values. It both worked well and was obvious to developers reading the code.


With named args, this no longer works. Though an argument passed externally does overwrite this.foo, setting an initial value to this.foo does not affect @foo in the template.

As a result, there is no way of specifying a default values for arguments!

The only workaround I'm aware of is to keep using {{foo}} or {{this.foo}} instead of {{@foo}}. This works, but it defeats the purpose of @-named args in templates.


Thus, we need a simple, declarative way of specifying default values for arguments used in components.

A developer should be able to tell which arguments are gonna have which default values by simply looking at the component JS file. Those definitions should not be obscured inside a hook such as init.

@lolmaus
Copy link
Author

lolmaus commented Apr 19, 2019

To be clear, I do NOT want to be using {{@foo}} for arguments without default values and {{this.foo}} for arguments with default values. Such approach creates a lot of confusion.

I want a way to provide default values for named args and keep using them as {{@foo}}, otherwise the named args RFC makes no sense to me.

This is true both for Classic and Glimmer components.

@NullVoxPopuli
Copy link
Sponsor Contributor

NullVoxPopuli commented Apr 19, 2019

Update (2021-05-23) (and after having spent a lot more time with Octane, and teaching it to folks)

I no longer think the opposite of @rwjblue
I am more... 🤷

To be clear, I do NOT want to be using {{@foo}} for arguments without default values and {{this.foo}} for arguments with default values. Such approach creates a lot of confusion.

fwiw, I think the opposite.

we know that {{@foo}} comes from the caller.
we know that {{this.foo}} is a properties in the current context/component.

(just for clarity, and thoroughness), at present, the only way to get the same behavior as in classic components is via:

get foo() {
  return this.args.foo || 'some default value';
}

and since args are readonly, we can't modify them -- we can't do any sort of dynamic default value setting (like what is possible in classic components).

The only option I really see for still using {{@foo}} and having a default value, is if we set static default values.

export default class MyComponent extends Component {
  static defaultArgs = {
    foo: 'some default value'
  };
}

however, this introduces an extra layer of complexity, and the merge of args with defaultArgs happens behind the scenes, and may just add 'one more thing' to a beginners set of things to learn / have to deal with.

my preference for a default value pattern would be something like this:

export default class MyComponent extends Component {
  @defaultArg('foo', () => 'the default value') foo;
}

and then still accessed via {{this.foo}}, because it's then a thing defined on the component.

However, this still uses a string to reference an arg, which I don't like, so the getter is my go-to for now.
(I avoid strings / any dynamic code via strings at all costs)

@chriskrycho
Copy link
Contributor

One thing to consider for a design around this is that one of the points of arguments is that they're immutable; so you'd want an approach—whether using decorators or something else—that lets that guarantee still be upheld for the idea of defaults.

Personally, I strongly prefer the explicitness of using a getter and saying "This is my own derived state," however it is derived. That makes it clear in the template that when I see @foo I'm looking at something that was passed in without any kind of transformation or alteration. A thing that can be passed optionally or can be given a different value has different semantics.

That said, optional arguments with defaults in JS are a thing, so I'm not opposed to coming up with a comparable solution here; I just want it to be the case that we don't lose the other benefits of named args.

@lolmaus
Copy link
Author

lolmaus commented Apr 19, 2019

@NullVoxPopuli: my preference for a default value pattern would be something like this:
and then still accessed via {{this.foo}}

@chriskrycho: I strongly prefer the explicitness of using a getter and saying "This is my own derived state," however it is derived.

I do not want to access some of my arguments via {{@foo}} and others via {{this.foo}}. It's hard to remember which is which. When you have a dozen of arguments, it's a huge pain.

Also, default values for arguments are a very common use case. It's not some caprice that developers, be they quirky enough to desire it, should figure out on their own.

The worst is that when providing a default value with a getter but not passing any value from the parent template, {{@foo}} and {{this.foo}} will have different values. And if I confuse one for another, I'll have unexpected results, with the code looking completely legit and leaving me clueless.

I don't want this gotcha to be scattered all over my code like rakes (gif inside). tom and jerry rake gif

@NullVoxPopuli:

export default class MyComponent extends Component {
  @defaultArg('foo', () => 'the default value') foo;
}

How is this ☝️ better than these 👇 ?

export default class MyComponent extends Component {
  foo = 'a default value';

  get bar() {
    return this.attrs.bar || 'another default value';
  }
}

Either way you still need to revert to {{this.foo}} in templates, which defeats the purpose of @-named args.


@chriskrycho: That makes it clear in the template that when I see @foo I'm looking at something that was passed in without any kind of transformation or alteration. A thing that can be passed optionally or can be given a different value has different semantics.

But arguments passed externally do get written into the this context of the component! That's the only thing I can be 100% certain about.

And I want to avoid mistakes and confusion, my only option is to always use {{this.foo}}. It will work predictably and will never cause any issues.

But I don't want to be a black sheep. And I do want to see a distinction between arguments and properties in my templates. It's just arguments with default values are still arguments! They've always been.

@lolmaus
Copy link
Author

lolmaus commented Apr 19, 2019

Note that there's no linter (and can hardly imagine one to exist) for warning you that you're using {{@foo}} whereas a default value is available.

@NullVoxPopuli
Copy link
Sponsor Contributor

When you have a dozen of arguments, it's a huge pain.

I think maybe we have something else to talk about now <3

Can you give an example of something where'd you have this many args?

. It's hard to remember which is which

fwiw, the point is not to remember, but to be able to discover. Apps are too big to keep in your head, ya know?

How is this point_up better than these point_down ?

imo, the getter is better, because there is no string access on args.

my only option is to always use {{this.foo}}.

that defeats the purpose of read-only args. ya don't always need a default. :-\

@lolmaus
Copy link
Author

lolmaus commented Apr 19, 2019

Oh wow, doing get foo() {} and passing @foo from a parent template crashes Ember! 🤦‍♂️

@lolmaus
Copy link
Author

lolmaus commented Apr 19, 2019

that defeats the purpose of read-only args.

That's what I said in the top post, and this is exactly the reason to request an RFC for default values.

@NullVoxPopuli
Copy link
Sponsor Contributor

Oh wow, doing get foo() {} and passing @foo from a parent template crashes Ember! man_facepalming

that's probably because we have to support classic accessing for those not fully on the named args train.

once we get rid of classic arg accessing, that won't be an issue

@lolmaus
Copy link
Author

lolmaus commented Apr 19, 2019

@chriskrycho: Personally, I strongly prefer the explicitness of using a getter and saying "This is my own derived state," however it is derived. That makes it clear in the template that when I see @foo I'm looking at something that was passed in without any kind of transformation or alteration. A thing that can be passed optionally or can be given a different value has different semantics.

I can agree with that for Glimmer components. They are more strict and are a new entity, so a shift of a mental model is acceptable.

But everyone's mental model for Classic components is that an argument can be accessed in a template the same way, regardless of whether it has a default value or not. Adding (or removing, for that matter!) a default value to/from an existing argument did not force me to update my template. It just worked, and that's how it should be.

The named arguments RFC changed that. 😒 But note how adding a default value now requires rewriting a template from {{@foo}} to {{this.foo}}, but removing a default value does not require switching back to {{@foo}}.

It is an inconsistency and a source of confusion. I've been always subconsciously uncomfortable using @ & this in Classic component templates and now I understand why.

@Panman82
Copy link

This was discussed elsewhere (apparently not on the RFC) and I seem to recall the answer (for the time being) was to be {{or @argName "static value"}} or {{or @argName this.argDefault}}. The only issue with that is what if the @argName is falsy and that's what you'd want. Maybe we need a {{default}} helper to only pass the second/default value when the arg is undefined. Related: #388

@NullVoxPopuli
Copy link
Sponsor Contributor

It is an inconsistency and a source of confusion.

you could still use classic / ambiguous access: {{foo}}

where,

import { Component } from '@ember/component';

export default class MyComponent extends Component.extend({
  foo: 'default value',
}) {
 // class body
}

@lolmaus
Copy link
Author

lolmaus commented Apr 19, 2019

@Panman8201:

{{or @argName "static value"}}

There are two huge issues with this approach:

  1. It's a pretty universal convention to have default values defined near the top of the component's JS class.
  2. What if the value is used multiple times in a template? You'll have to provide the default value multiple times. It's tedious and prone to regressions.

@lolmaus
Copy link
Author

lolmaus commented Apr 19, 2019

@NullVoxPopuli: you could still use classic / ambiguous access: {{foo}}

Which equally defeats the purpose of named arguments RFC.

BTW, you don't need to use .extend() for that. Class property works.

@Panman82
Copy link

Panman82 commented Apr 19, 2019

@lolmaus
There are two huge issues with this approach:

  1. It's a pretty universal convention to have default values defined near the top of the component's JS class.
  2. What if the value is used multiple times in a template? You'll have to provide the default value multiple times. It's tedious and prone to regressions.
  1. Putting defaults on the component JavaScript class won't be a viable solution for "template only" components. So the solution for this issue has to be in the template.
  2. That's why I put {{or @argName this.argDefault}} as another example as a way to show the option of putting the default value in one place to reference. Another template-only solution would be to {{let "some default" as |argDefault|}} and then {{or @argName argDefault}}.

@lolmaus
Copy link
Author

lolmaus commented Apr 19, 2019

putting defaults on the component JavaScript class won't be a viable solution for "template only" components

And restaurants are not a viable solution for people with food allergies. Is is a reason to ban restaurants that use fish, garlic, honey, dairy products, etc?

Note that having a meaningful way of defining default values for arguments does not prevent you from using {{or}} in a template-only component (likewise, having a fish allergy does not prevent you from eating non-fish dishes).

@NullVoxPopuli
Copy link
Sponsor Contributor

Which equally defeats the purpose of named arguments RFC.

imo, named args is more of a single piece towards getting to the Octane mental model -- it's not meant to be used with the old mental model where you can have mutable args.

BTW, you don't need to use .extend() for that. Class property works.

🤷‍♂️

@Panman82
Copy link

Panman82 commented Apr 19, 2019

And restaurants are not a viable solution for people with food allergies. Is is a reason to ban restaurants that use fish, garlic, honey, dairy products, etc?

Sorry, I'm not following this reference. I just wanted to provide a solution that will work in all scenarios.

Note that having a meaningful way of defining default values for arguments does not prevent you from using {{or}} in a template-only component (likewise, having a fish allergy does not prevent you from eating non-fish dishes).

The new Glimmer Components purposefully minimized the number of lifecycle hooks and such to reduce property name collisions, so IMO I'd rather avoid adding more things back in. Again, I was trying to highlight a solution that allows you to put default values in your component class, or just put them in your template. The developer gets to decide.

@lolmaus
Copy link
Author

lolmaus commented Apr 19, 2019

IMO I'd rather avoid adding more things back in

If Classic components are now considered to be transitional, and full migration to Glimmer components will be encouraged, then I agree that this is reasonable.

But I thought Classic components would not be deprecated?

@NullVoxPopuli
Copy link
Sponsor Contributor

But I thought Classic components would not be deprecated?

we'd need another RFC to deprecate anything in classic ember. I don't see that happening for a while though.

@pzuraq
Copy link
Contributor

pzuraq commented Apr 19, 2019

If Classic components are now considered to be transitional, and full migration to Glimmer components will be encouraged, then I agree that this is reasonable.

This is the long term goal, absolutely. We haven't fully deprecated classic components for a few reasons:

  1. We don't usually deprecate old features until the new features have landed, and we do this as a separate RFC. This generally prevents bikeshedding about the nature of the deprecation, and FUD that the new API won't meet the same needs of the deprecated API. Once the new APIs have landed, this give us a chance to ensure there is a transition path before we actually deprecate. Glimmer components are designed, but haven't quite shipped yet (as in a v1.0.0).
  2. This is going to be a major transition, so it's not clear what the deprecation timeline should be. It's possible we may need to keep the classic component API around until Ember v5, depending on when v4 lands, for instance.

I can confirm that named arguments were not ever meant to work particularly well with classic components. The fact is that they were designed with Glimmer components in mind.

Like @chriskrycho pointed out, there's a strong argument that including some API for setting them may muddy the waters a bit and make it harder to reason about a system that is now very pure in Glimmer components - this.arg.foo and @foo are both immutable, and guaranteed to be the same value, the value passed in by the external context. That said, default values are a very common use case, so there has been some discussion of adding an API that would allow users to specify defaults.

My personal strawman would be a second export, separate from both the class and the template, something like:

export const defaultArgs = {
  foo: 'bar',
  baz: () => [],
}

Note that initializer functions would be allowed so users could provide defaults for values like objects and arrays. This gets a bit tricky though, as it means we need to define a few things:

  1. When should defaults be assigned? When the argument is nullish? When it is undefined?
  2. Do default values only get assigned initially, or do they get assigned any time the argument is nullish/undefined (e.g. after updates to the argument/component)
  3. Can/should we guard against users accidentally providing a value like an array or object without using an initializer?
  4. Should default args be able to access incoming argument values when being calculated? My feeling is no, it would lead to a lot of complexity, but something to think about.

Another possible option would be a defaultArgs function:

export function defaultArgs() {
  return {
    foo: 'bar',
    baz: [],
  };
}

I imagine this could get expensive if used incorrectly though

@lolmaus
Copy link
Author

lolmaus commented Apr 19, 2019

@pzuraq, with Classic Ember, one could set up a computed property and get it overridden from a parent template:

Component.extend({
  name: null,
  slug: computed('name', function() {
    return snakeCase(this.name);
  })
})
{{my-component name="John Doe"}} => slug is rendered as "john_doe"
{{my-component name="John Doe" slug="big_j"}} => slug is rendered as "big_j"

I know that overwritable CPs are out of favor these days, but that technique was very simple and efficient. Modern alternatives require two distinct properties: slug-from-parent and slug-default, leaving a chance of mistake without the code structure providing clues about what's wrong and what the right usage should be.

@pzuraq, does your second export suggestion support referencing other arguments and properties like this?

@pzuraq
Copy link
Contributor

pzuraq commented Apr 19, 2019

Personally I would want to avoid this. Allowing default values to reference incoming arguments or component state means we will see a split between business logic in component classes and the defaultArgs values, and it’ll make things harder to follow. If you need logic like this, I would use a getter instead.

We could of course also add the ability to reference incoming args later on, if we find it would be valuable. What I would propose would be keeping it minimal - just plain primitive values - for the first iteration.

I don’t think we should ever add the ability to reference component state.

@lolmaus
Copy link
Author

lolmaus commented Apr 26, 2019

Before the named components RFC, this served as self-documentation for component arguments contract:

Component.extend({
  name: undefined,
  role: 'user',
})

Now not only providing argument defaults is impossible, but even documentation no longer makes sense, since this.name is now different from @name.

Even if y'all refuse to implement a reasonable way to specify argument defaults, there must still be some guidance! An officially recommended way of self-documenting component arguments and providing default values.

Is must be in the guides, mustn't it?

@NullVoxPopuli
Copy link
Sponsor Contributor

NullVoxPopuli commented Apr 26, 2019

Before the named components RFC, this served as self-documentation for component arguments contract

This kiiinda sounds like a similar role typescript would provide

interface IArgs {
  name?: string
  role: string
}

export default class MyComponent extends Component<IArgs> {

}

you then get earlier warnings/errors when misused! :)

Even if y'all refuse to implement a reasonable way to specify argument defaults, there must still be some guidance! An officially recommended way of self-documenting component arguments and providing default values.

I think we need an RFC, as we'd need a change to the component API.

I think something like this (inspired from React's prop-types system) is our best option (imo)

export default class MyComponent extends Component {
  static defaultArgs = {
    name: undefined,
    role: 'user',
  }
}

and things are still accessed via @name and @role / this.args.name and this.args.role

thoughts on that?

@pzuraq
Copy link
Contributor

pzuraq commented Apr 26, 2019

I don't think it's a good idea to place the default value directly on the class, because it'll force users to make a class and incur more overhead even if they want to make a template-only component (which I also see being more common in the future, especially with importable helpers and modifiers). I still think a separate named export makes the most sense, as it works whether or not there is a class for the component.

As for documentation, I think attempting to document components like classes is the wrong approach. In ember-cli-addon-docs, we specifically made component documentation document what matters to components - their public API, arguments and yields. All class fields and methods were considered private, internal implementation details:

Screen Shot 2019-04-26 at 10 56 15 AM

We achieved this by layering a post-processing step on top of documentation tools, such as ESDoc and YUIDoc. Both these documenters, and most others, output to JSON optionally, so the post-processing is relatively lightweight. Future documentation tools that are in the works (such as code-to-json) are focused on this use case primarily, because it's so flexible and allows this type of customization.

This strategy currently requires users to annotate each class field that they intend to be an argument (you annotate the entire class with the values yielded). Both @NullVoxPopuli's idea and my own would be much easier to automate, using TypeScript's type inference to detect the value's types (code-to-json uses TS even for normal JS) and falling back to annotations when required. I think they would be equivalent to automate given this, so I would prefer using a separate export for documentation.

I spent a lot of time thinking about how to make documentation better during my work on ec-addon-docs, so I'm definitely very aware of these concerns, and it's a very high priority to me to have a fully automated, minimal, maintainable solution 😄

@richard-viney
Copy link
Contributor

Note that there was some discussion about default args on the Glimmer components RFC: #416

One point that came up was not tying this to a component class, so that we were able to use default arg values on template-only components.

@NullVoxPopuli
Copy link
Sponsor Contributor

What if default args were defined in the template?

@Panman82
Copy link

Panman82 commented May 2, 2019

What if default args were defined in the template?

I thought that would be ideal but thinking more about it, either way will feel somewhat disconnected from each other. For example, would somehow putting defaults in the template effect this.args in the JS class? Going the other way feels a bit more natural but doesn't work with template-only components. Perhaps @pzuraq idea would be best since it would be somewhat separated from either "side". 🤷‍♂

@lolmaus
Copy link
Author

lolmaus commented May 2, 2019

It should be possible to define TypeScript types! The rest of the app should know which type each argument property has.

@chriskrycho
Copy link
Contributor

TS types already exist for Glimmer components; but importantly no design in this space can require an app to use TypeScript. More, TypeScript does not supply any run-time data, so it is not a useful bit for supplying default args.

@lolmaus
Copy link
Author

lolmaus commented May 2, 2019

@chriskrycho Wat? I never said I want to enforce TypeScript for everyone and I never wanted to extract run-time data from TypeScript. Was that a straw man attack 😬 ?

When I design a component, I want to define a type for each argument property -- in such a way that other portions of the app recognize those types and provide warnings when types don't match.

Obviously, I want to do it in the same place in code where I specify default arguments.

Previously, this was working perfectly (at least for Classic components):

class MyComponent extends Component {
  name!: string;   // required argument
  prefix?: string; // optional argument
  role = 'user';   // optional argument with a default value
}

This approach was serving three tightly interconnected purposes:

  • specifying default values for component arguments
  • self-documenting available component arguments
  • defining types for component arguments

The named arguments RFC broke all three.

@lolmaus
Copy link
Author

lolmaus commented May 2, 2019

TS types already exist for Glimmer components

What is the official way of defining types for Glimmer component arguments?

@chriskrycho
Copy link
Contributor

@lolmaus I was just clarifying for everyone reading the thread – no attack implied. 😄 In TS, your example would look like this:

interface Args {
  name: string;
  prefix?: string;
  role?: string;
}

class MyComponent extends Component<Args> {
  get role(): string {
    return this.args.role || 'user';
  }
}

I also disagree with your assertion that "the named arguments RFC broke all three" of these; what it did is separate them from the internal state of the component by moving them into their own namespace. I totally get that it's a big change, though!

@lolmaus
Copy link
Author

lolmaus commented May 2, 2019

@chriskrycho Ok, so I have the following issues with this approach.

  1. It's way too verbose.

    Compare this:

    role = 'user';
    

    to this:

    interface Args {
      role?: string;
    }
    
    get role(): string {
      return this.args.role || 'user';
    }
    

    There's so much boilerplate that it's hard to see the actual purpose.

    I could tolerate role: string = this.role || 'user'; but this is just too much.

  2. It requires me to remember which arguments have default values and which don't, and adjust the usage accordingly. It's a potential source of bugs.

  3. If I decide to provide a default value to an existing argument, I have to look up and update all its usages. It just doesn't feel right, and it's prone to regressions.

  4. Not sure how it works with Classic components.

@chriskrycho
Copy link
Contributor

@lolmaus I believe everyone understands your complaints at this point, as you've reiterated them several times now. I understand why you feel the way you do, but I disagree; I think the tradeoffs are worth it (having used variants on them for quite some time). 🤷‍♂

If you have a proposal, I think everyone would love to hear it, but repeating the same complaints isn't changing anything here.

@lolmaus
Copy link
Author

lolmaus commented May 2, 2019

repeating the same complaints isn't changing anything here

I've been reiterating on my complaints in a hope to achieve agreement that the issue is legit.


If you have a proposal

I've been thinking of employing decorators:

class MyComponent extends Component{
  @arg name!: string;
  @arg prefix?: string;
  @arg role = 'user';

  // Alternative:
  @arg('user') role: string;
}

Not sure how to do it for Glimmer components, though. :( Maybe a class decorator?

interface Args {
  name: string;
  prefix?: string;
  role?: string;
}

@args({
  role: 'user'
})
class MyComponent extends Component<Args> {
}

@pzuraq
Copy link
Contributor

pzuraq commented May 2, 2019

@lolmaus we absolutely think your concerns are legit.

For context, when originally designing namespaced args and Glimmer components, this issue did come up, and we discussed it in depth quite a few times. In all those discussions, we kept coming back to the many issues with the way that arguments work in classic components, and decided that the pros of namespaced arguments and separation of concerns outweighed the cons. The explicitness may feel verbose, but the extra safety it provides is generally worth it. This is similar to our current stance on Mixins.

That said, we also intentionally left room for future solutions here, such as a separate defaultArgs export. We realize this is a common enough use case that the verbosity is not ideal, and personally I really do want to see something better here. I also want that solution to have the benefits I discussed above:

  1. Separate from both the template and the class definitions, so neither is required
  2. Isolated from component state and incoming args state, so that defaulting doesn't become a magnet for business logic (as it can be currently)
  3. Easier to automate documentation for
  4. Ideally, a better typing strategy, especially assuming we do get template typing at some point. While I agree classic components were somewhat easier to type, I actually feel like there is some type smell there, especially around having to use ! to specify required values.

I realize that not having this ability right now is frustrating, but I think this is also part of the philosophy Ember is taking on right now - iterative steps and improvements, day over day and year over year, rather than complete rewrites that attempt to solve every problem all at once. There are plenty more features, ideas, and improvements we have in the wings for the post-Octane world, and we'll keep on shipping them one at a time 😄

Discussions like this help us prioritize and gather ideas for how to solve the problems better, so we absolutely appreciate you bringing this up, I just want to reiterate, we get where you're coming from, and we're taking it seriously. We just need to do the design work, and iterate toward a solid solution.

@gossi
Copy link

gossi commented May 5, 2019

I'm late to the party. I found this discussion somewhere left it open in a tab and now I had the time to read through it, totally worth it.
I can say, I totally had the same experience as @lolmaus with this. With sparkles rising, I created the @arg to quickly serve my direct needs. I wouldn't recommend using it today anymore, though it was a good learning experiment for me all along the way with sparkles (now glimmer) components and decorators. This project basically funnels into ember-decorators/argument#95

However, all along the way, you need to do a mindshift - that is particularly not easy when trying to bake old patterns into a new system (which easily become anti-patterns as in this case). So some things I learned, that I hope that help you (since not everything is finalized, those things may change):

  • Glimmer is template only, means you need to shift your priority (from code to template); it is: template (declarative) first, programming second. That also goes for didUpdate() => {{did-update}} (here I also had a missunderstanding of what it can do, it is here to make explicit calls if one of the args is changing - very helpful). That said doing {{or}} or {{optional}} plenty of times in a template is tedious and it is in our craft/nature as developers to seek for a smarter solution that stimulates our brain 😁

  • Set your initial state in the respective initializer (init() for classic, constructor() for glimmer):

interface MyArgs {
  foo?: string;
}

export default class MyComponent extends Component<MyArgs> {
  foo: string;
  constructor(...) {
    this.foo = this.args.foo || 'default goes here';
  }
}
  • If this.foo needs to be a dynamic value (depending on the passed in @foo) then use a getter (and praise auto-tracking)

  • this.foo and @foo are two separate and explicit things. Do not try to use them for the same thing (as with args on classic components). Just don't. (Re)think your architecture for that component, it will thank you (most of the times).

  • That said, not everything has landed in ember/glimmer/octane yet, things that will make our lives easier (e.g. not attributes to glimmer components yet, when using the {{component}} helper).

The best tip here (and I gonna repeat myself for this): It's a mindshift, embrace it. Throw your "old habited" balast over board and come into a clean, explicit and more structured ember-world :)

//cc @locks @jenweber -> is that something for the guides, too? Regarding the migration part?

@pzuraq
Copy link
Contributor

pzuraq commented Jul 15, 2019

I've been thinking about this more, along with the ability to specify some sort of ArgTypes similar to React's PropTypes, and one thing I'm coming back to and realizing is that in the future, we won't necessarily be guaranteed to have one component per-file. This is still something that needs to be figured out, what our SFC/template import syntax looks like, but if we do land on something that allows multiple components per file, my suggestion of export const defaultArgs won't work.

I think it may actually make sense to use static class properties instead. It could look something like:

export class MyClassBasedComponent extends Component {
  static defaultArgs = {};
}

export const MyTemplateOnlyComponent = <template></template>;

MyTemplateOnlyComponent.defaultArgs = {};

And the same could be done for some sort of ArgTypes system. The other option would be to have a different syntax specifically for TO-components, but I don't think that would be ideal. It'd make converting from a TO-component to a class-component much harder, and would make it harder to statically analyze.

@maxbogue
Copy link

I found reading through the comments for this quite frustrating because it seems I hold very different things important than most of the people here. That's going to make the rest of this post feel somewhat rant-y, which I apologize in advance for.

The named arguments RFC broke an extremely basic and commonly feature of components, which are the modern building block of front-end UI. The fact that it was accepted and implemented without any support for default values (all the current solutions are extremely bug-prone because anyone using @foo syntax won't get the default value) represents, in my mind, not some strength of Ember's new iterative approach but a massive disconnect with users and a huge threat to the framework's viability for new users relative to other popular options.

The two main considerations being expressed in this thread preventing a solution being agreed upon (many of the decorator-based solutions have seemed great to me) are things that I utterly disagree with. They are:

  1. That it is important to know whether a value comes from a caller or the default value. Why? Much like in JS functions, I don't see why it would. It's a value, you use it. If you care, then default values are not the feature you wanted in the first place. Many members of the Ember community seem to value "not needing to look at the JS to know the value", but from my perspective coming from other frameworks like Vue that's probably a relic of Ember's long history of failing to co-locate the template and the JS in any way. Putting the two closer together is the solution (as evidenced by other frameworks gravitating that way), not breaking things like default argument values so you don't need to pull up the JS file that's 6 directories up and 5 directories down...

  2. That it is worth prioritizing compatibility with template-only components over getting a solution to this issue using JS in place. Multiple people have brought this up as a concern but nobody has offered up a solution. That's because this feature is almost certainly not compatible with template-only components, nor should it need to be. I agree that it's cool to not need a class if you don't have anything besides the template to define for a component, but I think it's extremely dangerous to the future of the framework to start requiring new features to somehow support them.

It's hard to participate in a debate like this where the disagreements seem so fundamental, so thanks for bearing with me.

@pzuraq
Copy link
Contributor

pzuraq commented Nov 22, 2019

@maxbogue I understand the frustration. The core team has been in a holding pattern recently as we put the final touches on Octane, so we haven't been actively working on or reviewing RFCs, which is why progress has been slow.

I don't think we're as philosophically disconnected as it may seem though. For your first point, it's important to realize that you do get a clear distinction in a function definition as to what is an argument, and what is a local value.

function myFunction(arg = 'foo') {
  let localValue = 'bar';
}

What Ember did historically would be, more or less, translated to JS (not in a literal way, but in a semantically similar hypothetical) like this:

function myFunction() {
  this.arg = this.arg || 'foo';
  this.localValue = 'bar';
}

Personally I think this is much more difficult to reason about, and I think this is why a lot of folks both in the community and on the core team want to make sure we don't reintroduce semantics like this.

This doesn't mean that everyone is against defaults in general. JS also didn't have default values for arguments for quite some time, but they have definitely been valuable since they were introduced, and I think that we can probably find a solution that works for all of our use cases, and still maintains the semantics we worked for with named arguments.

Re: prioritizing template-only components (something I've mentioned a few times in this thread), IMO it's is not prioritizing them over class based components, but making sure we don't design ourselves into a corner. If we were to come up with a design for defaults that, for instance, limited our options for designing template-imports, that would not be ideal. If we were to come up with a design that had no way of translating into TO components, that would also not be ideal. It's mostly about doing our due-diligence, and making sure we've considered as much as possible in the design phase.

I'm hopeful that we'll be able to make progress on this soon! And again, I understand the frustration and hope the explanation helps a bit. Thanks for your feedback!

@maxbogue
Copy link

@maxbogue I understand the frustration. The core team has been in a holding pattern recently as we put the final touches on Octane, so we haven't been actively working on or reviewing RFCs, which is why progress has been slow.

I don't think we're as philosophically disconnected as it may seem though. For your first point, it's important to realize that you do get a clear distinction in a function definition as to what is an argument, and what is a local value.

function myFunction(arg = 'foo') {
  let localValue = 'bar';
}

What Ember did historically would be, more or less, translated to JS like this:

function myFunction() {
  this.arg = this.arg || 'foo';
  this.localValue = 'bar';
}

Personally I think this is much more difficult to reason about, and I think this is why a lot of folks both in the community and on the core team want to make sure we don't reintroduce semantics like this.

I like @foo syntax as a way to differentiate what is an argument, I just don't see the problem in wrapping the default value up into that (same as JS). Not against the syntax wholesale, just that it was implemented without solving this use-case.

Re: prioritizing template-only components (something I've mentioned a few times in this thread), IMO it's is not prioritizing them over class based components, but making sure we don't design ourselves into a corner. If we were to come up with a design for defaults that, for instance, limited our options for designing template-imports, that would not be ideal. If we were to come up with a design that had no way of translating into TO components, that would also not be ideal. It's mostly about doing our due-diligence, and making sure we've considered as much as possible in the design phase.

I guess it's pretty unclear to me how you would ever do something like this with TO components, because templates are just templates and the JS is everything else about a component. This is pretty clearly part of the everything else. Trying to make every component feature going forward TO-compatible seems pretty problematic to me.

@pzuraq
Copy link
Contributor

pzuraq commented Nov 22, 2019

I guess it's pretty unclear to me how you would ever do something like this with TO components, because templates are just templates and the JS is everything else about a component

So, for some extra context here, one thing that is being discussed a lot is making TO components much more viable for use in the majority of component situations. Today they're fairly limited in what they can do, but with template imports it may be possible to make them much more powerful.

import PowerSelect from 'ember-power-select';

function getCityNames(cities) {
  return cities.map(c => c.name);
}

function getCountryNames(cities) {
  let countries = new Set();

  cities.forEach((city) => countries.add(city.country));

  return Array.from(countries);
}

export default hbs`
  City: 
  
  <PowerSelect 
    @selected={{@selectedCity}}
    @options={{getCityNames @cities}}
    @onChange={{@onCityChange}}
  />

  Country:

  <PowerSelect
    @selected={{@selectedCountry}}
    @options={{getCountryNames @cities}}
    @onChange={{@onCountryChange}}
  />
`;

Or a frontmatter version:

---
import PowerSelect from 'ember-power-select';

function getCityNames(cities) {
  return cities.map(c => c.name);
}

function getCountryNames(cities) {
  let countries = new Set();

  cities.forEach((city) => countries.add(city.country));

  return Array.from(countries);
}
---

City: 

<PowerSelect 
  @selected={{@selectedCity}}
  @options={{getCityNames @cities}}
  @onChange={{@onCityChange}}
/>

Country:

<PowerSelect
  @selected={{@selectedCountry}}
  @options={{getCountryNames @cities}}
  @onChange={{@onCountryChange}}
/>

With this type of a setup, it would be possible to use TO components for many more use cases, especially when no root state exists on the component. Helper functions can take the place of computed properties/getters, and you could define modifiers inline as well potentially. Today, those would all have to be global, and the cost to setting them up is pretty prohibitive because of that.

This isn't all set in stone at all, template imports could also end up being much more minimal (e.g. only allow import syntax), but I do think it demonstrates the possibilities, and why we would like to make sure we think about them in general when designing new component APIs.

@Panman82
Copy link

Got to be honest, I'm not a fan of putting JavaScript in frontmatter, that belongs in the .js file. I'd rather work out the single file components solution. Unless this is the SFC solution, in that case, I still don't like it. 😛

@pzuraq
Copy link
Contributor

pzuraq commented Nov 23, 2019

There are a few different general directions that are being discussed, some that are even more minimal (no JS except import statements allowed). I posted two of the options mainly to demonstrate why some folks think TO components are worth investing into, myself included, and because I don't want to give the impression that anything has been decided - still plenty of RFCs to go before we finalize the syntax!

@webark
Copy link

webark commented Nov 23, 2019

@pzuraq This is a bit of a side tangent, but what’s the main driving force behind having that TO component you showed (which wasn’t just a template, but verbiage aside) over a standard/glimmer/single file/class component? I get the explicit import, but the rest just seems like needless confusion, and could be easily done with a template and a class rather then template and functions.

Is it performance? Memory usage? “Ergonomics”? If there’s a better place for this question, point me there and i’ll read ask their.

@pzuraq
Copy link
Contributor

pzuraq commented Nov 24, 2019

@webark let's continue this discussion on Discuss, started a thread over there

@wagenet
Copy link
Member

wagenet commented Jul 23, 2022

I'm closing this due to inactivity. This doesn't mean that the idea presented here is invalid, but that, unfortunately, nobody has taken the effort to spearhead it and bring it to completion. Please feel free to advocate for it if you believe that this is still worth pursuing. Thanks!

@wagenet wagenet closed this as completed Jul 23, 2022
@NullVoxPopuli
Copy link
Sponsor Contributor

NullVoxPopuli commented Jul 23, 2022

I think someone had an idea that layered on top of: #779 (and #800 / #748 )
and was something like:

interface Signature {
  Args: {
    foo?: string;
  }
}

export const Foo: TOC<Signature> = <template @foo="default value">
  always shows something: {{@foo}}
</template>

tho, maybe ??= would have better semantics? idk?

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

No branches or pull requests

10 participants