← Back to Home

Migrating to <template> tag

<template> tag lets us write template(s) in strict mode. Templates are more explicit and statically analyzable, so we can adopt more tools from the JavaScript ecosystem. There's less work for Embroider to do, so we can expect app builds to be faster.

<template> tag has been around for a few years now. With the help of addons, in components and tests since 2020 and in routes since 2023.1 In short, it's stable and you can likely use <template> tag in your projects today.

This post aims to accelerate adoption. I will show you step-by-step how to migrate an existing component, route, and test. Once you understand the idea, feel free to run my codemod to automate the steps.

1. Where did things come from?

To enter strict mode, we will be importing objects (e.g. components, helpers, modifiers) instead of letting string names in templates be somehow resolved. So let's first look at where things come from.

Package Objects
@ember/component Input, Textarea
@ember/helper array, concat, fn, get, hash, uniqueId
@ember/modifier on
@ember/routing LinkTo
@ember/template htmlSafe, trustHTML
@embroider/util ensureSafeComponent

The right column shows objects native to Ember. We name-import them from the path shown in the left. For example,

import { array } from '@ember/helper';

Notice, there's a naming convention:

  • Component names follow Pascal case. (Capitalize the first letter of each word.)
  • All other names follow camel case.

For Ember addons, you can always do a default-import. However, for each object, you have to remember the full path, add 1 line of code, and type many characters. It's also easy to introduce inconsistencies in the import name (the "local" name).

import ContainerQuery from 'ember-container-query/components/container-query';
import height from 'ember-container-query/helpers/height';
import width from 'ember-container-query/helpers/width';

Due to these reasons, always do name-imports when an addon provides a barrel file. If it doesn't, consider making a pull request.

import { ContainerQuery, height, width } from 'ember-container-query';

As an aside, some people may be against importing from a barrel file, as you may end up importing much more than needed. I believe that we should write code that's easy to read and maintain, and let bundlers handle transformations of barrel imports to full-path imports.

2. How to migrate

We will consider a component just complex enough to cover the important steps. You can use ember-workshop to test the code shown below. <Hello> is defined in my-addon, then rendered and tested in my-app.

a. Components

<Hello> is a Glimmer component that receives 1 argument. For simplicity, we will ignore the component signature.

my-addon: src/components/hello.module.css
.container {
  color: orange;
  font-style: italic;
}
 
.hide {
  animation: fade-out 0s ease-in 0s forwards;
}
 
.hide.after-3-sec {
  animation-delay: 3s;
}
 
@keyframes fade-out {
  to {
    height: 0;
    overflow: hidden;
    width: 0;
  }
}
my-addon: src/components/hello.hbs
<div
  class={{local
    this.styles
    "container"
    (if this.someCondition (array "hide" "after-3-sec"))
  }}
>
  {{t "hello.message" name=@name}}
</div>
my-addon: src/components/hello.ts
import Component from '@glimmer/component';
 
import styles from './hello.module.css';
 
interface HelloSignature {
  Args: {
    name: string;
  };
}
 
export default class Hello extends Component<HelloSignature> {
  styles = styles;
 
  get someCondition(): boolean {
    return true;
  }
}

1. Change the class' file extension to .gts. Insert an empty <template> tag at the end of the class body.

my-addon: src/components/hello.gts
import Component from '@glimmer/component';
 
import styles from './hello.module.css';
 
export default class Hello extends Component<HelloSignature> {
  styles = styles;
 
  get someCondition(): boolean {
    return true;
  }
+
+   <template>
+   </template>
}

2. Copy the template from .hbs and paste it into the <template> tag. Delete the .hbs file afterwards.

my-addon: src/components/hello.gts
import Component from '@glimmer/component';
 
import styles from './hello.module.css';
 
export default class Hello extends Component<HelloSignature> {
  styles = styles;
 
  get someCondition(): boolean {
    return true;
  }
 
  <template>
+     <div
+       class={{local
+         this.styles
+         "container"
+         (if this.someCondition (array "hide" "after-3-sec"))
+       }}
+     >
+       {{t "hello.message" name=@name}}
+     </div>
  </template>
}

3. Enter strict mode. That is, specify where things come from.

my-addon: src/components/hello.gts
+ import { array } from '@ember/helper';
import Component from '@glimmer/component';
+ import { t } from 'ember-intl';
+ import { local } from 'embroider-css-modules';
 
import styles from './hello.module.css';
 
export default class Hello extends Component<HelloSignature> {
  styles = styles;
 
  get someCondition(): boolean {
    return true;
  }
 
  <template>
    <div
      class={{local
        this.styles
        "container"
        (if this.someCondition (array "hide" "after-3-sec"))
      }}
    >
      {{t "hello.message" name=@name}}
    </div>
  </template>
}

4. (Optional) Remove unnecessary code. Examples include:

  • Constants passed to the template via the class
  • Template registry (the declare module block), if you no longer have to support *.hbs files or hbs tags.
  • ensureSafeComponent() from @embroider/util (pass the component directly)
  • One-off helpers
my-addon: src/components/hello.gts
import { array } from '@ember/helper';
import Component from '@glimmer/component';
import { t } from 'ember-intl';
import { local } from 'embroider-css-modules';
 
import styles from './hello.module.css';
 
export default class Hello extends Component<HelloSignature> {
-   styles = styles;
-
  get someCondition(): boolean {
    return true;
  }
 
  <template>
    <div
      class={{local
-         this.styles
+         styles
        "container"
        (if this.someCondition (array "hide" "after-3-sec"))
      }}
    >
      {{t "hello.message" name=@name}}
    </div>
  </template>
}

Note, we pass the signature of a Glimmer component to the base class Component. For template-only components, we can provide the signature in two ways:

my-addon: src/components/hello.gts
import type { TOC } from '@ember/component/template-only';
import { t } from 'ember-intl';
 
import styles from './hello.module.css';
 
const Hello: TOC<HelloSignature> = <template>
  <div class={{styles.container}}>
    {{t "hello.message" name=@name}}
  </div>
</template>;
 
export default Hello;
my-addon: src/components/hello.gts
import type { TOC } from '@ember/component/template-only';
import { t } from 'ember-intl';
 
import styles from './hello.module.css';
 
<template>
  <div class={{styles.container}}>
    {{t "hello.message" name=@name}}
  </div>
</template> satisfies TOC<HelloSignature>;

The first variation (assigns the template to a variable, uses type annotation) may help with debugging and searching code, as the variable gives a name to the component. The second (no assignment, use of satisfies) is what Ember CLI currently uses in its blueprints.

b. Routes

In my-app, the index route renders <Hello>:

my-app: app/templates/index.hbs
<div class={{this.styles.container}}>
  <Hello @name={{this.userName}}
</div>

We see that the controller provides styles and userName, a getter that returns some string.

1. Change the file extension to .gts. Surround the template with the <template> tag.

my-app: app/templates/index.gts
+ <template>
  <div class={{this.styles.container}}>
    <Hello @name={{this.userName}}
  </div>
+ </template>

2. Enter strict mode. Instead of this, write @controller to indicate things from the controller. Just like before, @model refers to the model hook's return value.

my-app: app/templates/index.gts
+ import { Hello } from 'my-addon';
+
<template>
-   <div class={{this.styles.container}}>
+   <div class={{@controller.styles.container}}>
-     <Hello @name={{this.userName}}
+     <Hello @name={{@controller.userName}}
  </div>
</template>

3. The prior code is enough for .gjs, but not for .gts. If the template includes @controller or @model, provide the signature (their types).

my-app: app/templates/index.gts
+ import type { TOC } from '@ember/component/template-only';
import { Hello } from 'my-addon';
+ import type IndexController from 'my-app/controllers/index';
+
+ interface IndexSignature {
+   Args: {
+     controller: IndexController;
+     model: unknown;
+   };
+ }
 
<template>
  <div class={{@controller.styles.container}}>
    <Hello @name={{@controller.userName}}
  </div>
- </template>
+ </template> satisfies TOC<IndexSignature>;

4. (Optional) Remove unnecessary code. Here, we can colocate the stylesheet so that we rely less on the controller (with the aim of removing it).

my-app: app/templates/index.gts
import type { TOC } from '@ember/component/template-only';
import { Hello } from 'my-addon';
import type IndexController from 'my-app/controllers/index';
+
+ import styles from './index.module.css';
 
interface IndexSignature {
  Args: {
    controller: IndexController;
    model: unknown;
  };
}
 
<template>
-   <div class={{@controller.styles.container}}>
+   <div class={{styles.container}}>
    <Hello @name={{@controller.userName}}
  </div>
</template> satisfies TOC<IndexSignature>;

We can even use a Glimmer component to remove states from the controller.

my-app: app/templates/index.gts
import Component from '@glimmer/component';
import { Hello } from 'my-addon';
 
import styles from './index.module.css';
 
interface IndexSignature {
  Args: {
    controller: unknown;
    model: unknown;
  };
}
 
export default class IndexRoute extends Component<IndexSignature> {
  get userName(): string {
    return 'Zoey';
  }
 
  <template>
    <div class={{styles.container}}>
      <Hello @name={{this.userName}}
    </div>
  </template>
}

c. Tests

Last but not least, let's update the test file for <Hello>.

my-app: tests/integration/components/hello-test.ts
import {
  render,
  type TestContext as BaseTestContext,
} from '@ember/test-helpers';
import { hbs } from 'ember-cli-htmlbars';
import { setupRenderingTest } from 'my-app/tests/helpers';
import { module, test } from 'qunit';
 
interface TestContext extends BaseTestContext {
  userName: string;
}
 
module('Integration | Component | hello', function (hooks) {
  setupRenderingTest(hooks);
 
  hooks.beforeEach(function (this: TestContext) {
    this.userName = 'Zoey';
  });
 
  test('it renders', async function (this: TestContext, assert) {
    await render<TestContext>(
      hbs`
        <Hello @name={{this.userName}} />
      `,
    );
 
    assert.dom().hasText('Hello, Zoey!');
  });
});

1. Change the file extension, replace the hbs tags with <template>, then enter strict mode.

my-app: tests/integration/components/hello-test.gts
import {
  render,
  type TestContext as BaseTestContext,
} from '@ember/test-helpers';
- import { hbs } from 'ember-cli-htmlbars';
+ import { Hello } from 'my-addon';
import { setupRenderingTest } from 'my-app/tests/helpers';
import { module, test } from 'qunit';
 
interface TestContext extends BaseTestContext {
  userName: string;
}
 
module('Integration | Component | hello', function (hooks) {
  setupRenderingTest(hooks);
 
  hooks.beforeEach(function (this: TestContext) {
    this.userName = 'Zoey';
  });
 
  test('it renders', async function (this: TestContext, assert) {
-     await render<TestContext>(
+     await render(
-       hbs`
+       <template>
        <Hello @name={{this.userName}} />
-       `,
+       </template>,
    );
 
    assert.dom().hasText('Hello, Zoey!');
  });
});

The template inside render() is in strict mode, so glint no longer needs an extra TestContext to analyze render().

2. You may have used QUnit's beforeEach hook to define things that will be passed to the template (for all tests of a module). Currently, a bug in Ember causes assertions to fail if we keep using this to pass things to the template.2

We can get around this issue (pun intended) in a few different ways. One is to create an alias of this, called self.

my-app: tests/integration/components/hello-test.gts
import {
  render,
  type TestContext as BaseTestContext,
} from '@ember/test-helpers';
import { Hello } from 'my-addon';
import { setupRenderingTest } from 'my-app/tests/helpers';
import { module, test } from 'qunit';
 
interface TestContext extends BaseTestContext {
  userName: string;
}
 
module('Integration | Component | hello', function (hooks) {
  setupRenderingTest(hooks);
 
  hooks.beforeEach(function (this: TestContext) {
    this.userName = 'Zoey';
  });
 
  test('it renders', async function (this: TestContext, assert) {
+     const self = this;
+
    await render(
      <template>
-         <Hello @name={{this.userName}} />
+         <Hello @name={{self.userName}} />
      </template>,
    );
 
    assert.dom().hasText('Hello, Zoey!');
  });
});

Another is to destructure this so that we can pass things as variables.

my-app: tests/integration/components/hello-test.gts
import {
  render,
  type TestContext as BaseTestContext,
} from '@ember/test-helpers';
import { Hello } from 'my-addon';
import { setupRenderingTest } from 'my-app/tests/helpers';
import { module, test } from 'qunit';
 
interface TestContext extends BaseTestContext {
  userName: string;
}
 
module('Integration | Component | hello', function (hooks) {
  setupRenderingTest(hooks);
 
  hooks.beforeEach(function (this: TestContext) {
    this.userName = 'Zoey';
  });
 
  test('it renders', async function (this: TestContext, assert) {
+     const { userName } = this;
+
    await render(
      <template>
-         <Hello @name={{this.userName}} />
+         <Hello @name={{userName}} />
      </template>,
    );
 
    assert.dom().hasText('Hello, Zoey!');
  });
});

A third option is to define variables at a global level (scoped to a test module). By doing so, we may be able to remove TestContext altogether.

my-app: tests/integration/components/hello-test.gts
import { render } from '@ember/test-helpers';
import { Hello } from 'my-addon';
import { setupRenderingTest } from 'my-app/tests/helpers';
import { module, test } from 'qunit';
 
module('Integration | Component | hello', function (hooks) {
  setupRenderingTest(hooks);
 
  const userName = 'Zoey';
 
  test('it renders', async function (assert) {
    await render(
      <template>
        <Hello @name={{userName}} />
      </template>,
    );
 
    assert.dom().hasText('Hello, Zoey!');
  });
});

Finally, to prevent misusing global variables, to customize test setups, or to facilitate the removal of dead code, you can define things locally instead.

my-app: tests/integration/components/hello-test.gts
import { render } from '@ember/test-helpers';
import { Hello } from 'my-addon';
import { setupRenderingTest } from 'my-app/tests/helpers';
import { module, test } from 'qunit';
 
module('Integration | Component | hello', function (hooks) {
  setupRenderingTest(hooks);
 
  test('it renders', async function (assert) {
    const userName = 'Zoey';
 
    await render(
      <template>
        <Hello @name={{userName}} />
      </template>,
    );
 
    assert.dom().hasText('Hello, Zoey!');
  });
});

3. ember-codemod-add-template-tags

Time to automate. In an earlier post, I revealed a codemod that performs static code analysis and supports apps, addons, and monorepos.

# From the workspace or package root
pnpx ember-codemod-add-template-tags

In case you'd like to incrementally migrate, the codemod also provides the options --convert and --folder.

# Components and tests only
pnpx ember-codemod-add-template-tags --convert components tests
 
# `ui/form` folder only
pnpx ember-codemod-add-template-tags --convert components tests --folder ui/form

Did you guess? The codemod performs the exact steps that you learned above.

Notes

You can find my presentation slides here (Keynote, PDF).

Footnotes

  1. You can remove ember-template-imports and ember-route-template if your app runs on a recent version of @embroider/core (4.x, which only supports Vite) and ember-source (6.3), respectively.

  2. The bug involves the notion of "lexical this." According to @embroider/template-tag-codemod, it is fixed in ember-source@6.4, but I couldn't locate the pull request and Ember Blog didn't mention the fix. At the time of writing, not all tests for ember-workshop passed with ember-source@6.7.