DEV Community

Isaac Lee
Isaac Lee

Posted on • Originally published at crunchingnumbers.live

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. 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';
Enter fullscreen mode Exit fullscreen mode

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';
Enter fullscreen mode Exit fullscreen mode

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';
Enter fullscreen mode Exit fullscreen mode

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;
  }
}
Enter fullscreen mode Exit fullscreen mode
{{! 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>
Enter fullscreen mode Exit fullscreen mode
/* 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;
  }
}
Enter fullscreen mode Exit fullscreen mode

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>
}
Enter fullscreen mode Exit fullscreen mode

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>
}
Enter fullscreen mode Exit fullscreen mode

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>
}
Enter fullscreen mode Exit fullscreen mode

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.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>
}
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode
/* 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>;
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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>;
Enter fullscreen mode Exit fullscreen mode

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>;
Enter fullscreen mode Exit fullscreen mode

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>
}
Enter fullscreen mode Exit fullscreen mode

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!');
  });
});
Enter fullscreen mode Exit fullscreen mode

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!');
  });
});
Enter fullscreen mode Exit fullscreen mode

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!');
  });
});
Enter fullscreen mode Exit fullscreen mode

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!');
  });
});
Enter fullscreen mode Exit fullscreen mode

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!');
  });
});
Enter fullscreen mode Exit fullscreen mode

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!');
  });
});
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Guess what? The codemod performs the exact steps that you learned above.

Top comments (0)