Migration from v8 to v9
TIP
Run pnpx @ember-intl/update to address breaking changes. The badge Run codemod appears next to those that the codemod can handle.
Breaking changes
Minimum requirements Run codemod
Install v2 of @ember-intl/lint, @ember-intl/v1-compat, and @ember-intl/vite to guarantee their compatibility with ember-intl@v9.
Projects with these versions are supported when issues arise.
- Ember 4.12 and above
- Node 22 and above
@ember/test-helpers5.x and above
Removed handling nested translation JSON Run codemod
From 5.x to 7.x, the intl service's addTranslations flattened a translation JSON (data representation of translation files), because the Node part of the addon, which was responsible for reading translation files, didn't handle flattening.
In 8.x, addTranslations continued to flatten a JSON even though @ember-intl/vite had already flattened it, because @ember-intl/v1-compat didn't handle flattening. That is, Vite apps encountered an unnecessary cost, while maintaining the ember-intl project became more difficult due to different implementations and unclear boundaries.
Going forward, all packages that load translations (i.e. lint, v1-compat, and vite) will be responsible for flattening a translation JSON. When you call intl.addTranslations(), you will need to ensure that translations (the 2nd parameter) is flat:
type TranslationKey = string;
type TranslationMessage = string;
- type TranslationJson = Record<TranslationJson | TranslationKey, TranslationMessage>;
+ type TranslationJson = Record<TranslationKey, TranslationMessage>;this.intl.addTranslations('en-us', {
- hello: {
- message: 'Hello, {name}!',
- },
+ 'hello.message': 'Hello, {name}!',
});You will need to update code if your tests rely on the test helper addTranslations or on setupIntl with translations (the 3rd parameter used to stub translations).
import { render } from '@ember/test-helpers';
import { setupIntl } from 'ember-intl/test-support';
import Hello from 'my-app/components/hello';
import { setupRenderingTest } from 'my-app/tests/helpers';
import { module, test } from 'qunit';
module('Integration | Component | hello', function (hooks) {
setupRenderingTest(hooks);
setupIntl(hooks, 'en-us', {
- hello: {
- message: 'Hi, {name}!',
- },
+ 'hello.message': 'Hi, {name}!',
});
// ...
});Removed handling options for formatMessage and t
From 5.x to 8.x, the intl service's formatMessage processed the object values that you passed to options (the 2nd parameter) when options.htmlSafe is set to true. It sanitized string values and converted SafeString to string by copying implementation details from ember-source (removed in 6.6.0). The same processing occurs in t, since it relies on formatMessage.
The additional code made maintenance more difficult. It didn't necessarily improve your developer experience either, because the output may differ from what you expect. To address both problems, formatMessage and t will no longer process the object values in options when options.htmlSafe is set to true.
The code diff below shows that, from 5.x to 8.x, you would have had to call trustHTML (formerly known as htmlSafe) from @ember/template to see the text Hello, Zoey! and the CSS classes message and emphasize. Now, you shouldn't call trustHTML if you want to see the same HTML output.
import { trustHTML } from '@ember/template';
import { render } from '@ember/test-helpers';
import { formatMessage } from 'ember-intl';
import { setupIntl } from 'ember-intl/test-support';
import { setupRenderingTest } from 'my-app/tests/helpers';
import { module, test } from 'qunit';
module('Integration | Helper | format-message', function (hooks) {
setupRenderingTest(hooks);
setupIntl(hooks, 'en-us');
const message = '<div class="message">Hello, {name}!</div>';
test('it renders', async function (assert) {
const name = '<span class="emphasize">Zoey</span>';
await render(
<template>
{{formatMessage message name=name}}
</template>,
);
assert
.dom()
.hasText(
'<div class="message">Hello, <span class="emphasize">Zoey</span>!</div>',
);
});
test('it renders (with options.htmlSafe)', async function (assert) {
const name = '<span class="emphasize">Zoey</span>';
await render(
<template>
{{formatMessage message htmlSafe=true name=name}}
</template>,
);
- assert.dom().hasText('Hello, <span class="emphasize">Zoey</span>!'); // ❌ Incorrect
+ assert.dom().hasText('Hello, Zoey!');
});
test('it renders (with options.htmlSafe and trustHTML)', async function (assert) {
const name = trustHTML(
'<span class="emphasize">Zoey</span>',
) as unknown as string;
await render(
<template>
{{formatMessage message htmlSafe=true name=name}}
</template>,
);
- assert.dom().hasText('Hello, Zoey!');
+ assert.dom().hasText('Hello, ,Zoey,!'); // ❌ Incorrect
});
});Removed handling spaces in translation folder name Run codemod
Apps can use wrapTranslationsWithNamespace (now called namespaceKeysByDir) to namespace translation keys by folder names. From 5.x to 7.x, ember-intl used to convert spaces in folder names to underscores. This implementation detail was likely seldom needed and was unknown to end-developers.
@ember-intl/lint, @ember-intl/v1-compat, and @ember-intl/vite will no longer normalize the folder names. If a subfolder in /translations has a space in its name, replace the space with the underscore _ to keep your source code the same.
Removed test helper t Run codemod
For a while, the documentation site recommended not using the test helper t because it creates a tautology: t(...) is equal to t(...) (here, the t refers to that from the intl service).
An hasText or includesText assertion, when combined with the test helper t, becomes weak: It only guarantees that the translation key is correct, not the rendered message.
IMPORTANT
An hasText or includesText assertion, combined with t, passes even when the translation is missing. The passing assertion can't guarantee that end-users see the right message.
If possible, load translations in tests and always pass the string that you expect to see to hasText and includesText. This way, you can easily know what the app displayed at a given time (static code analysis). You can also change translations with more confidence when assertions begin to fail.
- import { setupIntl, t } from 'ember-intl/test-support';
+ import { setupIntl } from 'ember-intl/test-support';
module('Integration | Component | hello', function (hooks) {
setupRenderingTest(hooks);
setupIntl(hooks, 'de-de');
test('it renders', async function (assert) {
await render(<template><Hello @name="Zoey" /></template>);
assert
.dom('[data-test-message]')
- .hasText(t('hello.message', { name: 'Zoey' }));
+ .hasText('Hallo, Zoey!');
});
});If you want to continue checking keys only, you can create a test helper:
- import { setupIntl, t } from 'ember-intl/test-support';
+ import { setupIntl } from 'ember-intl/test-support';
- import { setupRenderingTest } from 'my-app/tests/helpers';
+ import { setupRenderingTest, t } from 'my-app/tests/helpers';
module('Integration | Component | hello', function (hooks) {
setupRenderingTest(hooks);
setupIntl(hooks, 'de-de');
test('it renders', async function (assert) {
await render(<template><Hello @name="Zoey" /></template>);
assert
.dom('[data-test-message]')
.hasText(t('hello.message', { name: 'Zoey' }));
});
});import { getContext, type TestContext } from '@ember/test-helpers';
import type { IntlService } from 'ember-intl';
type TParameters = Parameters<IntlService['t']>;
export function t(key: TParameters[0], options?: TParameters[1]): string {
const { owner } = getContext() as TestContext;
const intl = owner.lookup('service:intl');
return intl.t(key, options);
}Renamed build options Run codemod
To clarify intent and remove references to implementation details in classic Ember, three of the four buildOptions keys that @ember-intl/lint@v1, @ember-intl/v1-compat@v1, and @ember-intl/vite@v1 (i.e. ember-intl@v8) relied on have been renamed.
| Before | After |
|---|---|
| inputPath | translationsDir |
| publicOnly | bundleSeparately |
| wrapTranslationsWithNamespace | namespaceKeysByDir |
These packages will throw an error if you continue to use the old key name. Note, the key name fallbackLocale remains the same.
Strictened type for primaryLocale
From 5.x to 8.x, the getter intl.primaryLocale had the type of string | undefined. The type undefined shouldn't occur in practice, because intl.setLocale() is assumed to have been called first (in the application route).
Going forward, intl.primaryLocale will have the stricter type of string. In development, ember-intl will throw an error if you access the getter without calling setLocale().
Run TypeScript (Glint) to see where to update code. The autofix from typescript-eslint can remove unnecessary type assertions (!'s).
type FirstDayOfWeek = 0 | 1 | 2 | 3 | 4 | 5 | 6;
function getFirstDayOfWeek(locale: string): FirstDayOfWeek {
const intlLocale = new Intl.Locale(locale);
const { firstDay } = intlLocale.getWeekInfo();
return (firstDay % 7) as FirstDayOfWeek;
}
- getFirstDayOfWeek(this.intl.primaryLocale!);
+ getFirstDayOfWeek(this.intl.primaryLocale);Strictened type for setLocale()
From 5.x to 8.x, the method intl.setLocale had the type of string | string[]. The type string[] allows an empty array, which shouldn't occur in practice.
Going forward, intl.setLocale() will have the stricter type of string | Locales, where Locales is defined to be an array with at least 1 element:
type Locales = [string, ...string[]];Run TypeScript (Glint) to see where to update code.
type SupportedLocale = 'de-de' | 'en-us';
export default class SelectLocale extends Component {
@service declare intl: Services['intl'];
updateLocale(value: SupportedLocale): void {
- let locale: string[];
+ let locale: [string, ...string[]];
switch (value) {
case 'de-de': {
locale = ['de-de', 'en-us'];
break;
}
case 'en-us': {
locale = ['en-us'];
break;
}
}
this.intl.setLocale(locale);
}
}NOTE
The type restriction may also affect your use of intl.exists, if you had passed an array for locale (the optional 2nd parameter).