i18next / react-i18next

Internationalization for react done right. Using the i18next i18n ecosystem.
https://react.i18next.com
MIT License
9.3k stars 1.03k forks source link

react-i18next: How to sync Browser and Express LanguageDetector? #471

Closed acmoune closed 6 years ago

acmoune commented 6 years ago

In my server's routes, I do something like this (Note: Thei18next-express-middleware is already in the pipeline, and Server Side Rendering is working good):

export default function (req, res) {
  const lng = req.language.toUpperCase()

  console.log(lng) // Always display [EN]

  MyModel
    .findOne(query, { fieldEN: 1, fieldFR: 1 })
    .exec()
    .then(res => reply(res[`field${lng}`]))
    .catch(err => handle(err))
}

So I try to return the right version of the field, base on the USER's selected language.

But no matter what language is selected on the browser side, on the server side it is always set to the default, EN.

Is there a way to let the server side LanguageDetector know about the current language on the browser side ?

This is my Client i18n init:

import i18n from 'i18next'
import Backend from 'i18next-xhr-backend'
import LanguageDetector from 'i18next-browser-languagedetector'

i18n
  .use(Backend)
  .use(LanguageDetector)
  .init({
    whitelist: ['en', 'fr'],
    fallbackLng: 'en',
    preload: ['en', 'fr'],

    // debug: true,

    interpolation: {
      escapeValue: false
    },

    ns: ['home', 'channel', 'common'],
    defaultNS: 'home',

    backend: {
      loadPath: '/locales/{{lng}}/{{ns}}.json'
    },

    react: {
      wait: true,
      bindI18n: 'languageChanged loaded',
      bindStore: 'added removed',
      nsMode: 'default'
    }
  })

export default i18n

And this is my serveri18n init:

import i18n from 'i18next'
import Backend from 'i18next-node-fs-backend'
import { LanguageDetector } from 'i18next-express-middleware'

i18n
  .use(Backend)
  .use(LanguageDetector)
  .init({
    whitelist: ['en', 'fr'],
    fallbackLng: 'en',
    preload: ['en', 'fr'],

    // debug: true,

    interpolation: {
      escapeValue: false
    },

    ns: ['home', 'channel', 'common'],
    defaultNS: 'home',

    backend: {
      loadPath: `${__dirname}/public/locales/{{lng}}/{{ns}}.json`,
      jsonIndent: 2
    }
  })

export default i18n
jamuhl commented 6 years ago

hm, is your detected language something like fr-FR?!? a language-Region combination if so that would not go through the whitelist -> you will need to additional set nonExplicitWhitelist on init - https://www.i18next.com/overview/configuration-options#languages-namespaces-resources

just guessing as everything else looks right...

acmoune commented 6 years ago

No, it is exactly FR, I set it myself withi18n.changeLanguage('fr') from React app.

I tried the nonExplicitWhitelist config. It seems to make req.language return en-US instead of en, even if the language is set to en. But

console.log(lng) // on top

is still returning 'EN-US', instead of 'FR-FR'.

So if I understand you well, both LanguageDetectors (browser and express) should naturally detect the same thing, I think from the browser's LocalStorage ?

jamuhl commented 6 years ago

the client detector is not running same time on server...the client pick up from server via set cookie. But on ssr you set it like initialStore via initialLanguage: https://react.i18next.com/components/translate-hoc#the-translate-hoc-props

if i get it right you do something like:

export default function (req, res) {
  const lng = req.language.toUpperCase()

  console.log(lng) // Always display [EN]

  MyModel
    .findOne(query, { fieldEN: 1, fieldFR: 1 })
    .exec()
    .then(res => reply(res[`field${lng}`]))
    .catch(err => handle(err))
}

in an express middleware and somewhere inside the react render on server you set language via i18n.changeLanguage('fr')...right?

That can't work middleware run before render...language there will be what detector detected if order of middlewares is right - setting it inside rendering is to late.

acmoune commented 6 years ago

I create a new middleware that add a component field to the request like this:

...
import i18n from '../i18n-server'

exports.addComponentToRequestPipeline = (req, res, next) => {
  const i18n_ = i18n.cloneInstance()
  const store = storeFactory(serverStore.getState())
  i18n_.changeLanguage(req.language)

  req.component = {
    state: store.getState(),
    html: renderToString(
      <I18nextProvider i18n={i18n_}>
        <Provider store={store}>
          <StaticRouter location={req.url} context={{}}>
            <Route component={App} />
          </StaticRouter>
        </Provider>
      </I18nextProvider>
    )
  }

  next()
}

This middleware is added after i18next-express-middleware.

So I use my req.component html and state to generate the markup.

Note that when I set a new language on React app, and I refresh the page, the new language is used, but only when I put wait: true. If not it is always in english after refresh. I admit that I am not really understanding what is going on.

jamuhl commented 6 years ago

you create a i18next instance clone - use that for serverside render. Do you set initialStore, initialLanguage for client?

using an own clone does nothing beside using that for render on server - the instance used on request does not change language but stays on its own language set. req.i18n

acmoune commented 6 years ago

I am trying to better understand your module and get it to work since ... but by the way I am getting a micro sub issue.

I am loading theinitialI18nStore on the server with this (from your razzle-ssr example):

const initialI18nStore = {}
  req.i18n.languages.forEach((l) => {
    initialI18nStore[l] = req.i18n.services.resourceStore.data[l]
  })

My backend option is set like this:

backend: {
    loadPath: `${__dirname}/public/locales/{{lng}}/{{ns}}.json`
}

But when I check the value of my window.initialI18nStore, from the page source on the browser, I see that only one langue (en) is loaded, with its namespaces.

Mylocales directory contains two folders, en and fr, with respectives json files. The preload option is set to ['en', 'fr'].

Is that normal ?

jamuhl commented 6 years ago

yes that is normal...if detection was en there is no reason to push down fr

jamuhl commented 6 years ago

req.i18n.languages is the array of languages it uses for the translation lookup....eg. for en-US it would be en-US -> en -> fallbackLng --> in your case with whitelist and fallback en only [en]...

if you like to pass down all preloaded you will need to change that to:

const initialI18nStore = {}
  req.i18n.options.preload.forEach((l) => {
    initialI18nStore[l] = req.i18n.services.resourceStore.data[l]
  })

but be aware the more languages you add the bigger this gets resulting in pushing all languages to the client...

acmoune commented 6 years ago

So i18n.services.resourceStore.data keeps all the translations.

On SSR I create the initialStore and set the initialLanguage. This will be done using the i18n-server init config. At this point, I load only the detected language's translations.

Now on the client side, on initial rendering, nothing will be added to the translations store, since the initialLanguage translations are there already.

As soon as I i18n.changeLanguage('another-language'), the translations store is populated with new translations from the added language.

Am I right up to there ?

Now, on the server side, once app.use(i18nMiddleware.handle(i18n)) is called, the req.i18n.language is set with whatever will be detected as the default language from thei18n-server config. So the initialLanguage for the client will always be set to that language on SSR, en in my case.

If I am still right then my question is: How can I update req.i18n.language according to the changed language from the client, before rendering on the server ? I think I should do it by calling i18n.changeLanguage(...) before app.use(i18nMiddleware.handle(i18n)), but where would I take the changed language ?

I don't know if the translations store is acting like the Redux store, so whatever update I do on the client store, I can first dispatch the same update to the server store, so on SSR, everything will be in sync.

jamuhl commented 6 years ago

First part yes...correct

Second...idk...there is no magic syncing the resourceStore from client to backend. There is nothing doing this for redux neither. On server resourceStore already contains every translation - while to client you only pass what is needed down there.

If I am still right then my question is: How can I update req.i18n.language according to the changed language from the client, before rendering on the server ?

You mean for a second server side render?

changeLanguage can set cookie on client...next load of a serverside rendered page will pass the cookie up to server which will take that for language detection.

acmoune commented 6 years ago

Ok. Thanks for the clarifications.

Now is there a convention for the cookie, so the server LanguageDetector will autmaticaly take its value, or is there a hook point to push the cookie value to the LanguageDetector, or should I call i18n.changeLanguage before app.use(i18nMiddleware.handle(i18n)) ?

jamuhl commented 6 years ago

for browser set: caches: ['localStorage', 'cookie'] https://github.com/i18next/i18next-browser-languageDetector#detector-options

same for server: https://github.com/i18next/i18next-express-middleware/blob/master/src/LanguageDetector.js#L17

acmoune commented 6 years ago

I am almost done. My only problem now is the language region. When I set the cookie, withi18n.changeLanguage(...), I have total control on the language format, I set it to either en or fr. Once set, the detector always take from there.

The problem is with the initial detection. The Detector always add the region, and I need to remove it.

This is my actual config, on both side (client and server)

...
whitelist: ['en', 'en-US', 'fr', 'fr-FR'],
fallbackLng: 'en',
load: 'languageOnly',
nonExplicitWhitelist: true,
...
jamuhl commented 6 years ago

detection will always be best match -> so i18next.language will be en-US but i18next.languages[0] will be best language you support. (as languageOnly is set it won't load or use en-US https://github.com/i18next/i18next/blob/master/src/LanguageUtils.js#L100)

acmoune commented 6 years ago

Thank you so much for your time, I am really grateful.