Angle brackets: Trouble passing a yielded hash to a contextual component


#1

Say I have a <DropdownList> component that can be used like this:

<DropdownList as |List|>
  <List.item>
    Menu A
  </List.item>
  <List.item>
    Menu B
  </List.item>
</DropdownList>

Now I’m working on a higher-level <NavigationMenu> component. It will use <DropdownList> & its contextual <Item> under the hood:

{{! navigation-menu.hbs }}

<DropdownList as |List|>
  {{yield (hash
    item=(component 'navigation-menu-item' List=List)
  )}}
</DropdownList>
{{! navigation-menu-item.hbs }}

<List.item>
  {{yield}}
</List.item>

However, this produces weird output: in the actual DOM tree, I see <list.item>:

Then, if I try to yield out in that component’s template

  {{! navigation-menu-item.hbs }}

- <List.item>
+ <List.item as |Item|>
    {{yield}}
  </List.item>

I get a compilation error:

The fix I’ve managed so far is instead of passing through the contextual List, I can pass through List.item:

  {{! navigation-menu.hbs }}

  <DropdownList as |List|>
    {{yield (hash
-     item=(component 'navigation-menu-item' List=List)
+     item=(component 'navigation-menu-item' Item=List.item)
    )}}
  </DropdownList>

and then render <Item> directly in my <NavigationMenuItem> component:

  {{! navigation-menu-item.hbs }}

- <List.item as |Item|>
+ <Item as |Item|>
    {{yield}}
- </List.item>
+ </Item>

and then everything works!

Could someone help me understand what’s going on?

  • Why does passing through List not work but List.item does? Are these things different when invoked from a parent vs. child template?
  • Am I using any of these things incorrectly?
  • The compilation error doesn’t make sense to me - how does the compiler know whether <List.item> is a component or not?

Thanks!


#2

Sam and I chatted a little bit about this in discord this afternoon. After a little bit of debugging, we figured out that the disconnect was actually an intentional limitation of the Angle Bracket Invocation feature. Specifically, when determining if a given angle bracket invocation should be treated as a “normal” DOM element (e.g. <div></div>) or as a component invocation we make the assumption that if the tag name being used is not referencing a block param or a named argument then it must be an actual component with the same name. In Sam’s examples above, you can see that this is actually a passed in argument though and isn’t an app/components/list.js soooooo, <List /> isn’t going to work :smiling_imp:.

OK, so why did we think it was going to work? Because we were relying on the property fallback behavior that has existed basically forever in our templates. We were trying to say <List /> was referencing the List argument passed in to our component. Indeed, if we did {{List}} we would find “something” (likely [object Object], but that is another story) printed out in the DOM. This is working by way of property lookup fallback semantics. Thankfully, this (IMHO very) confusing property lookup fallback behavior is on its way out the door. You can check out the deprecation RFC here.

So now we know that we were expecting the property lookup fallback behavior to make <List /> work here, but for some reason it isn’t. For insight we can take a look at the dynamic invocation section of the Angle Bracket Invocation RFC:

there are two exceptions to the general rule where certain technically valid Handlebars path expressions are not supported for dynamic invocations:

  • Implicit this lookups (a.k.a. “property fallback” in RFC #308
  • Slash lookups

First, while {{foo}} or {{Foo}} can normally refer to {{this.foo}} or {{this.Foo}} normally, allowing this implicitly lookup will mean any tag in the template (i.e. <foo /> or <Foo /> ) can possibly refer to a property on the current this context.

This ambiguity is highly undesirable for both human readers and the compiler, therefore implicitly this lookup is not allowed in angle bracket invocations. This explicit form, <this.foo /> and <this.Foo /> is required.

Now, lets get to the punch line, how do we make this work?!?! Well we have two options, we can either use named arguments or this

<@List />
<this.List />

I personally think that @List is preferable here because it makes it super clear when reading the template that this value came directly from the caller and is not being modified/mutated/etc in the components JS file.


Sorry for the long winded explanation, was fun to dig in here.

Thanks for the nicely articulated question @samselikoff!


#3

Thanks @rwjblue for the sleuthing + answer!

Using the @ sigil to refer to arguments feels so much better. It’s still very new to me so I need to get used to it, but it certainly makes it clear where things are coming from.

I love that every variable in a template can either be this, @ or referenced as {{foo}}from a local scope, and it’s no longer ambiguous where the data is coming from. I think having some linting or compiler warnings to nudge me towards always using one of these three syntaxes would be great (i.e. linting against the implicit lookup that you mentioned).

One thing that’s still confusing to me is why

<Item>

didn’t work, but

<Item as |Item|>

did. Seems like a possible bug – like the as |Item| declaration satisfied the complier (“alright, the dynamic invocation <Item> is now allowed!”) but somehow that “leaked” out of the block scope of the template. I’d love to understand why, or if this is a bug.

Anywho, @arg is what I’ll do going forward and it feels very nice! Thanks again.


#4

Yep, great point! The transition plan in the Property Lookup Fallback RFC says exactly the same thing.

The no-implicit-this rule was added to ember-template-lint to support the RFCs transition plan. I hope to have this rule enabled by default in ember-cli@3.8.0 applications (still need to do a bit of work to streamline the auto-fix before we enable it by default), but you can absolutely start using it right away in your projects. An example .template-lintrc.js file:

module.exports = {
 extends: 'recommended',

   rules: {
    'no-implicit-this': true
  }
};