Skip to content

Migration from v6 to v7

Breaking changes

Minimum requirements

Projects with these 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 the input value is undefined or null. (In v6, the methods had relied on isEmpty from @ember/utils and allowed more values to return an empty string.)

  • The format* methods don't use the option allowEmpty. Remove it from the invocation sites.

    diff
    {{! Display an empty string while @message is loaded }}
    - {{format-date @message.timestamp allowEmpty=true}}
    + {{format-date @message.timestamp}}
    diff
    import templateOnlyComponent from '@ember/component/template-only';
    
    interface MessageSignature {
      Args: {
        message?: {
          status: 'read' | 'received' | 'sent';
          timestamp: Date;
        };
      };
    }
    
    const Message = templateOnlyComponent<MessageSignature>();
    
    export default Message;
  • The t method no longer uses assert from @ember/debug to check that key (the 1st positional argument) is a string.

  • The t method doesn't use the option default. Remove it from the invocation sites. In the backing class, use early exit(s) and the intl service's exists instead.

    diff
    - {{t
    -   (concat "message.status-" @message.status)
    -   default="message.status-unknown"
    - }}
    + {{this.messageStatus}}
    diff
    import { type Registry as Services, service } from '@ember/service';
    import Component from '@glimmer/component';
    
    interface MessageSignature {
      Args: {
        message: {
          status: 'read' | 'received' | 'sent';
          timestamp: Date;
        };
      };
    }
    
    export default class Message extends Component<MessageSignature> {
    +   @service declare intl: Services['intl'];
    +
    +   get messageStatus(): string {
    +     const { message } = this.args;
    + 
    +     if (this.intl.exists(`message.status-${message.status}`)) {
    +       return this.intl.t(`message.status-${message.status}`);
    +     }
    + 
    +     return this.intl.t('message.status-unknown');
    +   }
    }
  • The t method doesn't use the option resilient. Remove it from the invocation sites. In the backing class, use early exit(s) and the intl service's exists instead.

    diff
    - {{t
    -   (concat "message.status-" @message.status)
    -   resilient=true
    - }}
    + {{this.messageStatus}}
    diff
    import { type Registry as Services, service } from '@ember/service';
    import Component from '@glimmer/component';
    
    interface MessageSignature {
      Args: {
        message: {
          status: 'read' | 'received' | 'sent';
          timestamp: Date;
        };
      };
    }
    
    export default class Message extends Component<MessageSignature> {
    +   @service declare intl: Services['intl'];
    +
    +   get messageStatus(): string | undefined {
    +     const { message } = this.args;
    + 
    +     if (this.intl.exists(`message.status-${message.status}`)) {
    +       return this.intl.t(`message.status-${message.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 iterates through the locales getter when a locale wasn't 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. Use setOnFormatjsError instead.

  • The translationsFor method has been removed. If you need to check that a translation exists, use exists instead.

    diff
    - if (!this.intl.translationsFor('de-de')) {
    + if (!this.intl.exists('some-special-key', 'de-de')) {
      // Load translations
    }

The service provides 2 new methods to help you configure ember-intl.

  • 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 should be removed.

Minimized API of helpers

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

  • Just like the service, the {{format-*}} helpers return an empty string when the input value is undefined or null. The option allowEmpty can be removed.

  • The {{format-*}} helpers no longer accept options via the 2nd positional argument. #1633 had accidentally allowed this (a bug), because the helpers had inherited a base class at the time. You can continue to pass data to the {{t}} helper using a named argument(s) or the 2nd positional argument.

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. This API can be considered private, because it wasn't mentioned in the documentation site and release notes as a feature.

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

  • The test helpers no longer use assert from @ember/debug to check that you have called setupTest, setupRenderingTest, or setupApplicationTest.

  • setupIntl no longer stores the intl service on this. In other words, this.intl is undefined by default. You can use this.owner.lookup('service:intl') if you need the intl service in a test.

  • setupIntl no longer allows options, a 4th positional argument that overrides ember-intl's implementation (missing message and formatters) and causes tests to pass or fail 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 1 translation file per locale (no support for JSON). In reality, a production app may have multiple files per locale. 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 unnecessary in Octane and prevent us from mainintaing ember-intl easily.

Check for files with the import path ember-intl/macros, then rewrite them to use native getters. This may require you to glimmerize a classic component.

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

export default class Example 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 exampleForIntl: string;

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

  @t('hello.message', {
    name: raw('name'),
  })
  declare exampleForTWithRaw: string;
}
ts
import { type Registry as Services, service } from '@ember/service';
import Component from '@glimmer/component';

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

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

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

  get exampleForTWithRaw(): 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 would have seen in v6.

This change may be breaking if you have a computed property that lists intl.locale or intl.primaryLocale as a dependent key. Consider removing the computed property (recommended) or moving the business logic in the computed property to a utility.

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

export default class Example 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.');
      }
    }
  }
}
ts
import { type Registry as Services, service } from '@ember/service';
import Component from '@glimmer/component';

export default class Example 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

In v6, ember-intl let you write setupIntl(hooks). Your tests could somehow pass, though you didn't specify under which locale the tests make sense.

By favoring convenience and assuming that most apps target USA, we had 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 have translations for en-us.

  • setupIntl needs to support 4 variations, increasing complexity and maintenance cost.

To solve these issues and encourage writing explicit code, 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, 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!')
  });
});