Template-Only Components - Uses, future ideas, possibilities, discussion, etc

Continuing this discussion from the RFCs repository, where things were getting a bit off topic on the named argument default values thread.

My original comment, outlining why myself and others believe that TO components could become much more valuable in the future:

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.

3 Likes

Responding to one of the last comments in the other thread:

@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 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.

So, I think there are a few different aspects that make it an interesting idea.

Performance

TO components require less to be done than even Glimmer components, which means they can be more optimal. Another possibility I mentioned previously in that thread was that TO components could be used to “subclass” rather than actual subclassing, and provide different default values that way.

Ergonomics

You definitely can define a class to do the things in that example, but the point is that all you really need is a getter to derive some values from an argument. A class body is bigger to write and more boilerplate overall, and there isn’t really much benefit to it in this case. Being able to write a quick helper function or modifier, especially when you may already be using a TO component and want to make a quick change, would be really valuable in these cases.

Personally, I think one of the only reasons helper functions aren’t more common is because you can’t define them next to the template they’re meant to be used in.

Stateless-ness

From a more general standpoint, TO components are Ember’s equivalent to stateless, functional components in other frameworks. They cannot have state on their own, so it’s much easier to reason about what’s going on in a TO component - it’s mapping from arguments -> DOM, directly.

This is definitely more of a philosophical angle - for instance, there’s that the idea that by defining a class, it can become a “magnet” for state that isn’t actually necessary (something I’ve seen a lot of in some code bases, personally). I’m also not at all saying that this should be the only way of doing things, but I could personally see writing an app that was a 70-30 or 80-20 split between TO components (stateless, presentational, etc) and class components (stateful, contain more data fetching and business logic, etc).

2 Likes

Another angle personally for me is the ergonomics of templates. Part of the reason I love Ember so much is it’s possible to really understand an app like HTML - you can read through the template, and get some basic idea of the structure and logic of the app very easily. @wycats did a great segment on this in the last EmberMap podcast.

This is also another reason why I would like to have a more template heavy app, personally - it means that I can learn more from the templates on their own, without having to switch into different files to read a class definition, etc.

2 Likes

I am still optimistically feeling that if one **needs ** to look in another file for meaning and understanding then the original author failed to properly abstract there things or name things well.

To me I would want to read a template and be confident that the abstractions used were small and will organized that I could gloss over their implementation as a Black Box where I know and understand what the output would be.

Maybe that is naive but I still can’t stand my JS in my HTML nor my headers in my C file. I personally applaud the separation of things. My editor does to.

By making template only the default we can encourage the good behavior though. Nudge them in the right direction

To me a “template heavy” component isn’t just about the JavaScript and Handlebars, it should be able to handle other things as well, such as styles and tests. In my opinion, rather than just allowing more JavaScript in the template frontmatter, we should work out the Single File Component solution.

FWIW, I really like the Vue SFC. I feel like editors play nice with the tag-based separation, and we can separate types even further with a play the type attribute (or something similar). Ex: <style type="text/css"> or <style type="text/sass">, <script type="module"> vs something like <script type="test/integration">, <template>, etc.

@Panman8201 the exact design of template imports is a bit orthogonal here. The first example I laid out would be an option for an SFC syntax. I think the Vue style is seen as not very popular for a few reasons:

  1. Essentially requires SFC, or at least requires a different solution for imports for non-SFC users. Ideally, IMO, the solution we land on should allow us to have separate files for component classes and templates still, in part to allow us to update incrementally, and in part because it is still a valid way to write components.

  2. The lexical scoping of separate tags makes imports themselves really tricky. In Vue, they solve this by re-exporting every import:

    <script>
    import TodoList from './components/TodoList.vue'
    
    export default {
      components: {
        TodoList
      }
    }
    </script>
    
    <template>
      <div id="app">
        <h1>My Todo App!</h1>
        <TodoList/>
      </div>
    </template>
    

    Re-exporting probably wouldn’t make sense in Ember, as you would have to prefix every value with <this.MyComponent/>, so would end up with something like:

    <script>
      import TodoList from './components/TodoList.ember'
    </script>
    
    <template>
      <div id="app">
        <h1>My Todo App!</h1>
        <TodoList/>
      </div>
    </template>
    

    Which is quite odd, since by normal HTML/JS rules, and even general programming rules about lexical scope, this shouldn’t work. TodoList shouldn’t be able to “escape” the <script> tag.

  3. It will not work for rendering tests. To be fair, neither will frontmatter style templates, but I think Vue style templates would be a larger difference between the two, making it more difficult.

    For reference, the first proposal using the hbs tag would look like this in rendering tests:

    import TodoList from '../components/TodoList';
    
    module('TodoList', () => {
      test('it renders', async () => {
        await render(hbs`
          <TodoList />
        `);
      });
    });
    

That’s not to say it’s completely off the table, just that a lot of issues have been raised and we need to think it through.

FWIW, my original examples could also work in a Vue like SFC world:

<script>
  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);
  }
</script>

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

  Country:

  <PowerSelect
    @selected={{@selectedCountry}}
    @options={{getCountryNames @cities}}
    @onChange={{@onCountryChange}}
  />
</template>
  1. I figured a different extension would be used to signify the different file/component type, such as *.sfc or *.ec or whatever. That would allow either option (but both option at one time should error).
  2. Components used in the template could still be placed in the frontmatter of the <template> (or some other means of defining template needs).
  3. Not sure I follow on this one… Importing a SFC and rendering it would render the component as a whole, not separating the class from template. No?

FWIW, I’m hoping that low-level API’s will be exposed for this type of file experimentation (I think that was already brought up). I just don’t want to go down a hole that negatively impacts/limits this type of use case by allowing more JS in the frontmatter without thinking about SFC as well.

Lower level APIs will definitely be exposed to allow experimentation first, just like with component managers and modifier managers :smile:

Not sure I follow on this one… Importing a SFC and rendering it would render the component as a whole, not separating the class from template. No?

How would you import a component for use in tests, with the Vue-SFC-style syntax?

module('TodoList', () => {
  test('it renders', async () => {
    await render(hbs`
      <script>
        import TodoList from '../components/TodoList';
      </script>
      <template>
        <TodoList />
      </template>
    `);
  });
});

Seems a bit verbose, and implies that we will need a different method for this. So, if the Vue-style were the default, we would need at least 2 different ways to do template imports by default.

I realized I haven’t also demo’d what the hbs SFC would look like:

import Component, { hbs } from '@glimmer/component';

import PowerSelect from 'ember-power-select';

export default class CityAndCountry extends Component {
  getCityNames() {
    return this.args.cities.map(c => c.name);
  }

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

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

    return Array.from(countries);
  }

  template = hbs`
    City: 
    
    <PowerSelect 
      @selected={{@selectedCity}}
      @options={{this.getCityNames}}
      @onChange={{@onCityChange}}
    />

    Country:

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

And how it would work with separate template/JS definitions:

// app/components/city-and-country/template.js
import { hbs } from '@glimmer/component';
import PowerSelect from 'ember-power-select';

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

  Country:

  <PowerSelect
    @selected={{@selectedCountry}}
    @options={{this.getCountryNames}}
    @onChange={{@onCountryChange}}
  />
`;
// app/components/city-and-country/index.js
import Component from '@glimmer/component';
import template from './template';

export default class CityAndCountry extends Component {
  template = template;

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

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

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

    return Array.from(countries);
  }
} 

Also, to be clear, when I say frontmatter I mean code between --- at the top of an hbs file. Just to make sure we’re on the same page here :sweat_smile: From my perspective, there are three options discussed here so far: Vue-style, frontmatter, or inline-in-JS (hbs tag). There are also other possibilities out there, these are just the ones in this thread.

Currently you don’t need to import the comonent for an integration test, it “just works” from the built app. (If you’re doing a unit test and import from JavaScript-land, I’d expect the component class.) But I suppose moving towards the frontmatter direction of importing components in the template, what about having frontmatter in the test template too? Seems natural to me anyway. :man_shrugging: Same would apply in a SFC template, the <template> could also have frontmatter itself. Ex:

await render(hbs`
---
import TodoList from 'app/components/TodoList';
---
<TodoList />
`);
<template>
---
import TodoList from 'app/components/TodoList';
---
<TodoList />
</template>

What is the need to import a component in JavaScript? Only to extend/reexport it is all I can think of. So the Vue style of importing components and exporting them for availability in the template is not needed with frontmatter.

I could also see your JS example where you have hbs'' in the component class for the template. But I also think we need to think a bit further about what else can live in a SFC besides the component class and template (styles, tests, etc.). My thought is that editors would have an easier time with tag-based solutions.

:+1:

I feel like having to type the import statements at the top of every single test would be fairly restrictive and difficult to maintain. Personally, I’d rather have 2 ways of doing things, one using the hbs tag and one using the other syntax.

I also think that we should definitely not use frontmatter and Vue style components together, personally. That seems like introducing 3 potential systems to solve one problem, which would be monumentally difficult to maintain.

We can of course hash out all of the details once we have the primitives, but just looking at the maintenance side, I’d really like to have one build system that worked for everything, and one method for imports that we can use everywhere.

Humm… I feel like what I was proposing is one way of doing it, importing template specific things in the frontmatter. (Yes, it would be a pain for tests. Or, if tests were within SFC then it would infer an import the component in the test hbs.?.)

I can see where there is a mental model issue if you happened to need to import something in both js and hbs, a helper for example (helper function in js and the helper iteself in hbs). But I guess those are two different things anyway, so it makes sense at the same time.

So I agree that the import/reexport thing with Vue SFC is wonky, so why not have all template specific things imported within the template specifically? None of this “if it happens to be imported in the class file, then your ok, if not, then import it in the hbs file”.

Now, I can’t speak from the build side of things. How you keep track of all this isn’t something I’ve dealt with before and is why I need folks like you. :smile: But my thought is the build system would know of a component by name (or path or whatever) but also understand there are different pieces of the component (class, template, potentially styles and tests). Then the build system would have to keep track of imported things for each of the “pieces” of the component (js has its’ own, and template has its’ own). Sure, double (or more) of the tracking work, but the build system should be able to handle that. (Honestly, all of this is very hand-wavey and just thoughts in my head, I can only speak from a DX perspective.)

Having said all this, I also feel that SFC only make sense when they’re smaller components. I would still separate things with larger components. So there is still a need/place for file separation as well.

Humm… I feel like what I was proposing is one way of doing it, importing template specific things in the frontmatter.

I think this means we can’t use helper functions and other values defined inline in the script tag then, no? You would have to define them in a separate file, or export and re-import them somehow.

I can see where there is a mental model issue if you happened to need to import something in both js and hbs, a helper for example (helper function in js and the helper iteself in hbs). But I guess those are two different things anyway, so it makes sense at the same time.

That could change in the future. One idea is to create default managers that would allow you to use plain functions in templates (by automatically wrapping them as helpers).

Good point. What about making a property on the component a helper? Sure you have to this. in the template but then you know where it came from…

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

export default class FroalaContentComponent extends Component {
    myHelper = helper(function(params, hash){
        // do stuff here
    });
}
<h1>My Component</h1>
{{this.myHelper "param1" "param2"}}

I would really like to hear Yahuda’s opinion on this. I was under the impression there was very compelling reasons the HTMLbars language was preferred over other ideas that mix languages into one file.

To be honest I don’t see the extreme draw to compile many languages into one god file. It confuses things for me. Every app I have ever worked on always has templates hundreds of lines long and JS twice as much. Having components that compound that would drive me away from web development all together.

In personally values the idea that my JS can flesh out the data and behavior while my templates can focus on rendering DOM. The relationship between them is by concept or the problem they solve and less about the logic of implementation.

I guess this is like I would try to mix my ember-data model into my route file even though they serve the same purpose in the end.

My JS in a component brokers the data down and actions up while the template knows how to take that massaged data and render it and attach events to actions that I need to deal with. For me that is two very distinct and separate concerns appropriate for two distinct and separate files.

To be honest I don’t see the extreme draw to compile many languages into one god file. It confuses things for me.

Very much agree here, and with the rest of your points @sukima. This is the main reason an SFC-only solution is a non-starter to me, and why I don’t think SFCs should be the default.

For me, using hbs template tags is a good compromise, as it allows both patterns, but also allows them to stay separate. It also is very much a pragmatic choice in my mind, as like I’ve pointed out a few times, we will have to build out the tooling for it anyways for tests.

Going back to TO-components and expanding their usage, you could also totally have them be separate files and still get the same benefits I mentioned in the beginning of this thread:

// app/components/city-and-country/index.js
import PowerSelect from 'ember-power-select';
import { getCityNames, getCountryNames } from './helpers';

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

  Country:

  <PowerSelect
    @selected={{@selectedCountry}}
    @options={{getCountryNames @cities}}
    @onChange={{@onCountryChange}}
  />
`;
// app/components/city-and-country/helpers.js
export function getCityNames(cities) {
  return cities.map(c => c.name);
}

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

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

  return Array.from(countries);
}

This would also work for a front matter style or Vue style imports setup. Do you think this would keep the aspects of separation of JS and template logic that you like? And do you think this would make TO components more valuable?

1 Like

First the export default hbs example above would make both syntax highlighting in most editors a monstrous amount of complexity and static analysis tools much less flexible as we lost the JS and the HBS language at the same time. HTML has this problem with inline script and style tags.

That technicality aside @pzuraq I think you are in the perfect track. I would only wish for a new HTMLbars-like syntax so not mix languages. But the idea of scoped things like you demonstrated is perfect!

1 Like

I already have hbs working in vim with syntax highlighting and language server integration

FWIW on that point, there have been a few different ideas tossed around for the exact syntax we could use for embedding. The two main options are template tags, like hbs:

// app/components/city-and-country/index.js
import { hbs } from '@glimmer/component';
import PowerSelect from 'ember-power-select';
import { getCityNames, getCountryNames } from './helpers';

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

  Country:

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

And a more JSX like syntax, where the beginning and end of the template are marked with <template>:

// app/components/city-and-country/index.js
import PowerSelect from 'ember-power-select';
import { getCityNames, getCountryNames } from './helpers';

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

  Country:

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

I’ve been exploring both options to figure out which one would be easier to implement and integrate into existing tooling. So far, the template tag is winning for a few reasons:

  1. Actually parses without any changes to JS parsers. <template> would likely mean we need to create a fork, or convince the TS and JS parsers to allow their JSX parsing to be generalized. So far, it seems like it’d be easier to integrate a template tag with existing tooling, such as ESlint, Typescript, and Babel because of this.
  2. Doesn’t require a new file format. <template> would require us to make a new file format of some kind, along with adding support for that format to every editor. Additionally, we’d have to convince GitHub and other VCS systems to highlight this file format. By contrast, GitHub already highlights gql and html template tags in JS: https://gist.github.com/pzuraq/5dbc5c0c35be805d738a5ba71f0fbeb1. So, there’s precedent to convince them to highlight hbs tags the same way.

That said, I really do prefer <template> syntactically, it reads much nicer, and puts emphasis on the fact that it is meant to be a template. hbs would be a lot less work potentially, and easier to maintain, but <template> (or another syntax, if anyone has ideas!) could be worth it for the clarity it would provide.