Skip to content

Migration from v6 to v7

Breaking changes

Minimum requirements

The following versions are supported when issues arise.

  • Ember 3.28 and above1
  • Node 18 and above
  • TypeScript 5 and above

1. Ember 4.4 and 4.8 are no longer checked in CI.

Minimized API of the intl service

The intl service provides just the essentials so that we can reduce the package size and maintain the project easily. The service has been rewritten to provide native types. We highly recommend that you use TypeScript and Glint.

  • The format*() methods return an empty string when value (the 1st positional argument) is undefined or null. (In v6, the methods had relied on isEmpty() from @ember/utils, potentially allowing more values for value to return an empty string.)

  • The format*() methods don't make use of options.allowEmpty. Remove the option at invocation sites.

diff
{{! Display an empty string while @message is loaded, i.e. @message.timestamp is undefined or null }}
- {{format-date @message.timestamp allowEmpty=true}}
+ {{format-date @message.timestamp}}
  • The t() method no longer uses assert() to check that key (the 1st positional argument) is string.

  • The t() method doesn't make use of options.default. Remove the option at invocation sites. In the backing class, use early exit(s) and the intl service's exists() to make the logic clear.

diff
- {{t
-   (concat "components.message.status-" @message.status)
-   default="components.message.status-unknown"
- }}
+ {{this.messageStatus}}
diff
import { type Registry as Services, service } from '@ember/service';
import Component from '@glimmer/component';

export default class MessageComponent extends Component {
  @service declare intl: Services['intl'];

+   get messageStatus(): string {
+     const { status } = this.args.message;
+ 
+     if (this.intl.exists(`components.message.status-${status}`)) {
+       return this.intl.t(`components.message.status-${status}`);
+     }
+ 
+     return this.intl.t('components.message.status-unknown');
+   }
}
  • The t() method doesn't make use of options.resilient. Remove the option at invocation sites. In the backing class, use early exit(s) and the intl service's exists() to make the logic clear.
diff
- {{t
-   (concat "components.message.status-" @message.status)
-   resilient=true
- }}
+ {{this.messageStatus}}
diff
import { type Registry as Services, service } from '@ember/service';
import Component from '@glimmer/component';

export default class MessageComponent extends Component {
  @service declare intl: Services['intl'];

+   get messageStatus(): string | undefined {
+     const { status } = this.args.message;
+ 
+     if (this.intl.exists(`components.message.status-${status}`)) {
+       return this.intl.t(`components.message.status-${status}`);
+     }
+   }
}
  • The locale getter and setter have been removed. Use setLocale() to set the locale.
diff
import Route from '@ember/routing/route';
import { type Registry as Services, service } from '@ember/service';

export default class ApplicationRoute extends Route {
  @service declare intl: Services['intl'];

  beforeModel(): void {
    this.setupIntl();
  }

  private setupIntl(): void {
-     this.intl.locale = ['en-us'];
+     this.intl.setLocale(['en-us']);
  }
}
  • The lookup() method, which iterated through the locales getter when a locale hadn't been specified, has been removed. v7 provides a simpler method called getTranslation(), one that requires you to specify the locale.

If you need the translation message for the primary locale:

diff
- const translation = this.intl.lookup('hello.message');
+ const translation = this.intl.getTranslation('hello.message', this.intl.primaryLocale);

If you need to replicate how lookup() iterated through locales:

ts
let translation?: string;

for (const locale of this.intl.locales) {
  const candidate = this.intl.getTranslation('hello.message', locale);

  if (candidate) {
    translation = candidate;
    break;
  }
}
  • The onIntlError() method has been removed. See setOnFormatjsError() below.

  • The translationsFor() method has been removed. If you need to check the existence of translations, use exists() instead.

diff
- if (this.intl.translationsFor('de-de')) {
+ if (this.intl.exists('some-key', 'de-de')) {
  return;
}

// ... (e.g. load translations)

The service provides 2 new methods to help you configure ember-intl. For more information, see the documentation for intl service.

  • setOnFormatjsError() lets you define what to do when @formatjs/intl errors.
  • setOnMissingTranslation() lets you define what to display when a translation is missing. The file app/utils/intl/missing-message.js has no effect and can be removed.

Minimized API of helpers

The {{format-*}} and {{t}} helpers are simply a shortcut for calling the corresponding intl service's method in a template.

  • The intl service's format*() methods return an empty string when value (the 1st positional argument) is undefined or null. This means, the {{format-*}} helpers also return an empty string. options.allowEmpty has no effect and can be removed.
  • The {{format-*}} helpers disallow passing options as the 2nd positional argument. #1633 had accidentally allowed this (a bug), because the helpers had inherited a base class at the time. The {{t}} helper continues to allow passing data as named or positional arguments.

Minimized API of test helpers

#1432, which had been released in v5.5.0-beta.7, increased the API in addon-test-support to help write tests for the ember-intl repo. We should consider the added code as private APIs, because they weren't mentioned in the documentation site and release notes as features.

By removing unnecessary code, we can reduce the package size and maintenance cost.

  • The test helpers no longer use assert() to check that you have called setupTest(), setupRenderingTest(), or setupApplicationTest().
  • setupIntl() no longer sets this.intl. So, by default, this.intl is undefined in tests. You can use owner.lookup('service:intl') if you need the intl service.
  • setupIntl() doesn't allow passing options, a 4th argument that overrides implementation (missing message and formatters) and may cause tests to pass under wrong assumptions.
  • ember-intl no longer provides the custom types TestContext and IntlTestContext. Always import TestContext from @ember/test-helpers.

Removed ember generate translation

This command has been removed, because the blueprints only addressed the simple case of a single translation file per locale (always *.yaml), not the more realistic case of multiple files per locale (possibly with nested folders). The blueprints also required installing a dependency.

How translation files are created will be left up to the end-developers.

Removed @intl and @t macros

The macros are a remnant of classic components and ember-i18n. They are not necessary in Octane, and prevent us from mainintaing and updating ember-intl more easily.

Check your codebase for import statements with ember-intl/macros, then rewrite code using getters. This may require you to glimmerize a classic component.

Before:

ts
import Component from '@ember/component';
import { intl, raw, t } from 'ember-intl/macros';

export default class MyComponent extends Component {
  @intl('fruits', function (_intl: Services['intl']) {
    // @ts-expect-error: 'this' implicitly has type 'any' because it does not have a type annotation.
    return _intl.formatList(this.fruits);
  })
  declare outputForIntl: string;

  @t('hello.message', {
    name: 'name',
  })
  declare outputForT: string;

  @t('hello.message', {
    name: raw('name'),
  })
  declare outputForTWithRaw: string;
}

After:

ts
import { type Registry as Services, service } from '@ember/service';
import Component from '@glimmer/component';

export default class MyComponent extends Component {
  @service declare intl: Services['intl'];

  get outputForIntl(): string {
    return this.intl.formatList(this.args.fruits);
  }

  get outputForT(): string {
    return this.intl.t('hello.message', {
      name: this.args.name,
    });
  }

  get outputForTWithRaw(): string {
    return this.intl.t('hello.message', {
      name: 'name',
    });
  }
}

Removed the use of @dependentKeyCompat decorator

The @dependentKeyCompat decorator was used to support the @intl and @t macros. Now that these macros are gone, so is @dependentKeyCompat.

This change should fix the error You attempted to update _locale [...] in the same computation. that you might have seen in ember-intl@v6.

This change may be breaking if you have a computed property that lists intl.locale or intl.primaryLocale as a dependent key. You may try to glimmerize the classic component (recommended) or move the logic inside the computed property "outside."

Before:

ts
import Component from '@ember/component';
import { computed } from '@ember/object';
import { type Registry as Services, service } from '@ember/service';

export default class MyComponent extends Component {
  @service declare intl: Services['intl'];

  @computed('intl.{locale,primaryLocale}')
  get fruits(): string[] {
    switch (this.intl.primaryLocale) {
      case 'de-de': {
        return ['Äpfel', 'Bananen', 'Orangen'];
      }

      case 'en-us': {
        return ['Apples', 'Bananas', 'Oranges'];
      }

      default: {
        throw new Error('Locale must be de-de or en-us.');
      }
    }
  }
}

After:

ts
import { type Registry as Services, service } from '@ember/service';
import Component from '@glimmer/component';

export default class MyComponent extends Component {
  @service declare intl: Services['intl'];

  get fruits(): string[] {
    switch (this.intl.primaryLocale) {
      case 'de-de': {
        return ['Äpfel', 'Bananen', 'Orangen'];
      }

      case 'en-us': {
        return ['Apples', 'Bananas', 'Oranges'];
      }

      default: {
        throw new Error('Locale must be de-de or en-us.');
      }
    }
  }
}

Required locale in test helpers

Before v7, ember-intl allowed you to write setupIntl(hooks). Your tests would somehow pass, even though you didn't specify under which locale the tests make sense.

By favoring convenience and assuming that most apps target USA, we also created problems that became visible in v6:

  • 'en-us' is always present in the intl service's locales, even when the app doesn't support the en-us locale.
  • setupIntl() needs to support 4 variations, increasing complexity and maintenance cost.

To solve these issues and encourage writing code that is explicit, setupIntl() now requires you to specify the locale. To migrate code, use find-and-replace-all in your text editor.

diff
module('Integration | Component | hello', function (hooks) {
  setupRenderingTest(hooks);
-   setupIntl(hooks);
+   setupIntl(hooks, 'en-us');

  test('it renders', async function (assert) {
    await render(hbs`
      <Hello @name="Zoey" />
    `);

    assert.dom('[data-test-message]').hasText('Hello, Zoey!');
  });
});

TIP

If you want to test multiple locales, you can use nested modules.

ts
module('Integration | Component | hello', function (hooks) {
  setupRenderingTest(hooks);

  module('de-de', function (nestedHooks) {
    setupIntl(nestedHooks, 'de-de');

    test('it renders', async function (assert) {
      await render(hbs`
        <Hello @name="Zoey" />
      `);

      assert.dom('[data-test-message]').hasText('Hallo, Zoey!');
    });
  });

  module('en-us', function (nestedHooks) {
    setupIntl(nestedHooks, 'en-us');

    test('it renders', async function (assert) {
      await render(hbs`
        <Hello @name="Zoey" />
      `);

      assert.dom('[data-test-message]').hasText('Hello, Zoey!');
    });
  });
});

Similarly, addTranslations() now requires the locale.

diff
module('Integration | Component | lazy-hello', function (hooks) {
  setupRenderingTest(hooks);
  setupIntl(hooks, 'en-us');

  test('Lazily loaded translations', async function (assert) {
    await render(hbs`
      <LazyHello @name="Zoey" />
    `);

    assert
      .dom('[data-test-message]')
      .hasText('t:lazy-hello.message:("name":"Zoey")');

-     await addTranslations({
+     await addTranslations('en-us', {
      'lazy-hello': {
        message: 'Hello, {name}!',
      },
    });

    assert.dom('[data-test-message]').hasText('Hello, Zoey!')
  });
});