rfcs icon indicating copy to clipboard operation
rfcs copied to clipboard

Sugar for multi-assignment with `let` block helper

Open sclaxton opened this issue 4 years ago • 13 comments

Using block let to assign multiple template variables quickly becomes a pain to read the more variables you want to assign, especially when you're assigning values derived from more complex expressions:

  {{#let (if cond valueA valueB) (some-helper arg1 arg2) (hash a=1 b=2) as |var1 var2 var3|}}
    {{var1}}
    {{var2}}
    {{var3}}
  {{/let}}

This is already pretty painful to read and it's a simple toy example.

IMO the reason why this is painful to read is because there's cognitive overhead required to positionally map the values with the variable they're being assigned to. To fix this, I've seen the following pattern used:

  {{#let (hash 
    var1=(if cond valueA valueB)
    var2=(some-helper arg1 arg2)
    var3=(hash a=1 b=2)) 
   as |vars|}}
    {{vars.var1}}
    {{vars.var2}}
    {{vars.var3}}
  {{/let}}

This fixes the mental overhead of positional mapping, but it introduces the overhead of naming the hash of the variables as well as the overhead of having to grab those variables off the hash everywhere.

I propose the following syntactic sugar for multi-assignment with block let:

  {{#let 
    (if cond valueA valueB) as |var1| 
    (some-helper arg1 arg2) as |var2|
    (hash a=1 b=2) as |var3|}}
    {{var1}}
    {{var2}}
    {{var3}}
  {{/let}}

This solves both of the issues with the current methods of multi-assignment: no mental positional mapping required and no hash overhead.

Another alternative proposal that solves the current issues with block let multi-assignment is the inline let proposal (as implemented in ember-let). As discussed here, adding inline let isn't as straight forward because it introduces new scoping semantics into the templating language, which may or may not be desirable. The advantage of the above syntactic sugar solution is that the semantics of the language remain the same.

sclaxton avatar Mar 09 '20 09:03 sclaxton

I'm not sure this is a good idea because it ultimately beaks the consistency of template syntax, it perhaps makes new users getting confused about as |...|. They may think like: hmm...can I use multiple as |...| for other helpers/keywords/components? If not, what makes the let helper so special?"

But let's assume this is an acceptable change, then I'm thinking a more intuitive solution as:

{{!
  1. not to introduce a new scoping semantic
  2. declare variables like JS version `let`
  3. use them as local variables
}}
{{#let
  foo=(if cond valueA valueB)
  bar=(some-helper arg1 arg2)
  qux=(hash a=1 b=2)
}}
  {{foo}}
  {{bar}}
  {{qux.a}} {{qux.b}}
{{/let}}

{{!
  1. namespace is optional by using extra `as ||`
  2. namespace will collect all local variables automatically, the user only need to name it
}}
{{#let
  foo=(if cond valueA valueB)
  bar=(some-helper arg1 arg2)
  qux=(hash a=1 b=2)
as |context|}}
  {{context.foo}}
  {{context.bar}}
  {{context.qux.a}} {{context.qux.b}}
{{/let}}

This is enough for like 95% usage, and I have some more ideas around it:

{{! 
  this.fetchedParams is an async task like what Promise.all or RSVP.hash did
  and I'm always dreaming about the possibility of basic deconstruction
  the as |...| part is optional
}}
{{#let |paramsA paramsB|=(await this.fetchedParams)}} as |promise|
  {{#let
    foo=(component (concat "custom-ui/" paramsA.name))
    bar=(component (concat "custom-ui/" paramsB.name)) as |UI|
  }}
    <UI.foo /> and <UI.bar />
 {{/let}}
{{else}}
  {{!
    render else block if this.fetchedParams rejected
    I'm not sure this is possible or not for let helper
    but since if/unless/each can have it, maybe let can have it.
    I wish the let helper can automatically detect if there are any async operations
    and use else block to handle the rejected promise
  }}
  <Common::ErrorMessage @message={{promise.message}} />
{{/let}}

Okay, I know the second part is a bit impractical, it's just a rough idea came up suddenly when I saw this topic. Rational rejection is welcomed. 😝

nightire avatar Mar 10 '20 02:03 nightire

I like the await helper proposal. The else it's more like a try/catch block. I think it would make sense to discuss both separately from the multi-assignment.

jelhan avatar Mar 11 '20 05:03 jelhan

I agree with @nightire that this will be confusing to new users. Specifically they will not understand why they can't do similar things elsewhere, like:

{{#each
  @model as |item|
  stocks as |stock|
}}
  {{item.name}}
  {{stock.quantity}}
{{/each}}

even though it clearly doesn't make sense.

Gaurav0 avatar Mar 21 '20 18:03 Gaurav0

@Gaurav0 I was actually thinking about that example in particular. I don't think it "clearly doesn't make sense". In fact, it intuitively looks like you're traversing two lists in parallel, which may or may not be useful, but it makes sense to me...

I do agree that a better mental model for what this syntax means and where it can and cannot be used would be necessary. Still thinking about that.

sclaxton avatar Mar 21 '20 21:03 sclaxton

Just to throw another example in the ring, I think we could probably make something like this work:

{{#let foo=(whatever) bar="something else" as |@bar @foo|}}
  
{{/let}}

Basically allowing named arguments to be yielded and have them match the hash args not be positional at all.

rwjblue avatar May 20 '20 17:05 rwjblue

@rwjblue Liking your variation of this. Definitely an improvement over the current state. Do you envision this being syntactic sugar for destructuring block params in general? I.e. something like this would work:

template.hbs

{{#full-name firstName="Rob" lastName="Jackson" as |@fullName|}}
  {{!-- ... --}}
{{/full-name}}

full-name.hbs

{{yield (hash fullName=(concat @firstName " " @lastName))}}

Edit: Guess the only downside to this would be that @arg no longer unequivocally means it's a component argument, since it could also be a named block param...

sclaxton avatar May 28 '20 20:05 sclaxton

What yielded argument would be destructed? The first argument? The first argument, which is an object? It's easier for helpers cause they can not return multiple arguments.

jelhan avatar May 28 '20 20:05 jelhan

@jelhan Yeah, I had this thought immediately after my reply haha. each is actually an example of a block helper that does yield multiple positional params, which presents a problem for this hmmm.

sclaxton avatar May 28 '20 20:05 sclaxton

Leaving this open for now since it's part of the meta issue.

wagenet avatar Jul 23 '22 00:07 wagenet

Is there a path forward here?

wagenet avatar Jul 25 '22 17:07 wagenet

Is there a path forward here?

wagenet avatar Jul 25 '22 17:07 wagenet

Personally, I don't see this as a big issue, but I think that's because I hardly let multiple things in one go. If I would, I would use the hash approach. I guess I'm wondering if the issue is big enough for introducing a new let syntax.

bertdeblock avatar Sep 09 '22 19:09 bertdeblock

I suspect that at least some of the felt need for multi-item let (or a non-block let) will be resolved by the combination of <template> and the default helper manager. That would be extra true if we had object or tuple shorthand syntax (or, more generally, a subset of JS expression syntax). Not eliminated, per se, but dramatically reduced.

chriskrycho avatar Sep 09 '22 19:09 chriskrycho