Progressive Ember - Lazy Locales
A walk through the real effort to transition an enterprise level Ember application to a progressive web application.
Part three: Lazy Locales
Primer
First, a quick overview of what an internationalized Ember application looks like. I’m using ember-intl and the basic gist is:
In templates, all strings are rendered with the t
helper:
<p>{{t 'path.to.string'}}</p>
ember-intl
resolves path.to.string
to a corresponding JSON file based on the user’s language.
/translations/en-us.json
:
{
"path": {
"to": {
"string": "Hello!"
}
}
}
translations/it-it.json
:
{
"path": {
"to": {
"string": "Ciao!"
}
}
}
ember-intl
by default includes all of you translation content in your application build. My application is very content heavy and is currently supporting 5 locales. This leads me to a whopping 45 kB in my application (roughly half the size of my entire application build).
The majority of my users don’t switch languages, so it would be in everyone’s interest to only load languages that are needed.
Low and Lazy
The bulk of the how-to is right from the ember-intl docs. However there are some gotchas and we’ll go over those.
First, tell ember-intl
to exclude locales from the build:
// config/ember-intl.js
module.exports = function() {
return {
publicOnly: true
}
};
Next, I’m going to inform ember-intl
what locales I support:
// config/ember-intl.js
module.exports = function() {
return {
locales: ['en-us', 'es-es', 'fr-fr', 'pt-br', 'it-it']
}
};
Finally, in the few spots where languages need to load (on boot, on language switch), load the translations from my asset map:
import { task } from 'ember-concurrency';
import fetch from 'fetch';
import { get } from '@ember/object';
import { inject as service } from '@ember/service';
export default Route.extend({
intl: service(),
assetMap: service('asset-map'),
_fetchTranslations: task(function*() {
const intl = get(this, 'intl');
const [locale] = get(intl, 'locale');
let translationsUrl = yield get(this, 'assetMap').resolve(`translations/${locale}.json`);
const translationSource = yield fetch(translationsUrl);
const translations = yield translationSource.json();
intl.addTranslations(locale, translations);
intl.setLocale(locale);
})
...
Pretty easy. It’s basically the same as the ember-intl
documentation.
Quirks
Mirage
I need to tell Mirage to let requests for translations passthrough and not treat them like an API request. Add the following to the Mirage config:
// local Ember development
this.passthrough('http://localhost:4200/translations/**', ['get']);
// Travis
this.passthrough('http://localhost:7357/translations/**', ['get']);
Hosting Assets
It’s absolutely critical that you use a CDN to upload your assets. At the time, we were hosting our application (and serving assets) from Heroku, which is ephemeral. Given the nature of the single page application, users tend to have longer running sessions. We saw a lot of cases where:
- user has
app-ABC.js
running, which has locales built in. the asset map hasen-us-ABC.json
- the deployment goes out,
en-us-ABC.json
is destroyed, replaced withen-us-XYZ.json
- user still has
app-ABC.js
and now can’t see any content becauseapp-ABC.json
is gone
Results
Our previous build and lighthouse:
After:
This is great, we dropped 74 kB from our application build (not vendor build), which was half of our entire application. The tradeoff here is users now incur an additional web request to fetch initial content.
The first meaningful paint time has dropped lower to 4.3 seconds. Our JavaScript boot time and main thread time is half of what it was when I started.
Lighthouse is still bothering me about loading fonts, so let’s revisit that next.