<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. 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 |
@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';
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.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.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.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.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.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 orhbs
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.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.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.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.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.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.
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
Guess what? The codemod performs the exact steps that you learned above.
Top comments (0)