i18next / react-i18next

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

Pattern for SSR without match, render order with translate and misunderstanding of wait property #266

Closed TimoRuetten closed 7 years ago

TimoRuetten commented 7 years ago

I think I have problems to get the SSR work without the match function from react-router (because its not provided in v4 anymore).

For my understanding I am on a good way, but I have some problems with the translate() wrapper, the wait property. It seems, that the translate() wrapper will always prevent the possibility for doing the SSR correct.

My sample code (pseudo code):

<App />

This is my main Component. The I18nextProvider will get the client-side i18n or the server side i18n (when doing ssr).

class App {
    componentDidMount() {
        console.log('App mounted');
    }
    render() {
        return (
            <I18nextProvider store={this.props.i18n || i18n}>
                <MyComponent />
            </I18nextProvider>
        );
    }
}

Client I18n

Here the i18n instance will be returned for client. I will init it with the resourcedata of the SSR process so the namespace does not to have loaded ASYNC and is directly ready.

import i18n from 'i18next';
i18n.use(XHR).init({});
i18n.addResourceBundle('en', 'translation', initI18nRessources.en.translation);
export i18n;

<MyComponent />

MyComponent is the component which has something to translate.

class MyComponent {
    render() {
        console.log('render mycomponent');
        return (<h1>{this.props.t('helloWorld')}</h1>);
    }
}

module.exports = translate()(MyComponent);

SSR server.js

Here we do the SSR. The Server has a global i18n instance which has all ressources loaded sync ready and will provide its data for the server-side rendering. This workd perfect - all the components get rendered correctly on Server.

    ...
    App = renderToString(<App i18n={serverI18n} />);
    res.send(
        <head>
            <script>
                initI18nRessources=serverI18n.store.data
            </script>
        </head>
        <html>
            <div id="react-root">App</div>
        </html>
    );
    ...

This is my code (just the important parts of it)

1) As you can see in <App /> and <MyComponent /> I have console.logs. This is because I saw smth which will cause big problems in my App logic. When I wrap <MyComponent /> with translate it will render after <App /> get mounted. If I remove the wrap the Component will render correctly before App get rendered. This will break some fetchData logic I have implemented in my App and also seems to be a bit weird. I dont know if this is an error or not.

2) Without any params for translate() I will get a Markup error because on client the namespace seems not to be ready. When I am doing this: translate(null, { wait: false })(MyComponent) I will also get the Markup error. When doing wait to true I will not get a Markup error. But <MyComponent /> will still render after <App /> got mounted.

3) Just for testing I set the wait var in the init process for react.wait to true and removed the translate params back to nothing but then the Markup error appears again. So it seems that the react.wait parameter does not affect the translate method in my case.

--

My goal now is (and also the question): How to get it work that I can init the i18next-instance on client so the namespace will be available immediately (for myunderstanding it should work with addRessourceBundle but it seems not) and how to solve that the translate wrap will not prevent the component for rendering (when the namespace is still available) so it does not loose its order logic of mounted and rendered.

I hope you can understand my problem. Maybe there is no solution yet - and if not I will try to do my own wrap method which will provide for me what I need.

If there is still a good solution for my problem it would be also nice to add a "How to solve SSR with react-router v4"-example in the documentation.

Thanks in advance

jamuhl commented 7 years ago

there is no "official" solution yet - i personally never needed ssr - but today started doing a sample with next.js https://github.com/i18next/react-i18next/tree/master/example/nextjs to get a better feeling for it.

what i did there was adding two new props on react-i18next translate hoc: https://github.com/i18next/react-i18next/blob/master/src/translate.js#L28 this enables passing initial translations from server to client (setting i18next.services.resourceStore.data directly) without running into translations not ready...also doing so we set wait to false - as we know we got the data already.

Would be great if you could add a react-router v4 sample to this repo...PR would be welcome...having the basic setup i could also look into getting it to work (wasn't easy for nextjs too - but there is always a solution)

TimoRuetten commented 7 years ago

Thanks for your response.

Yes - there is always a solution :) I will work tomorrow at a solution for react-router v4 for my case. As now I think its not possible so I would have to fork your project and to modify smth or I have to write a new wrapper. Its late here so I will continue tomorrow but of course if I'll find a good solution I will let you know

TimoRuetten commented 7 years ago

Hey @jamuhl - just a thought: When I init the i18n on client and set a resourceBundle directly or inside the init process (resources) the data is directly available in the i18n instance - isnt it ? So what about when you just check in the Translate-HOC if the language is still available ? I dont get why you would need to pass the init translations also through your components when you just can assign them to the i18next instance. Or am I missing smth ?

i18n.use().init(...).addResourceBundle(en, translation, { hello: 'Hello!' });
i18n.t('hello'); // directly sync available
// some time pass by....
// i18next: initialized.
jamuhl commented 7 years ago

i had to do it in the translate hoc - out of how nextjs works - wouldn't be my favorite approach.

i18next.init(...).addResourceBundle(...) will trigger a load for translations (if there is a backend) as init call is done first.

i18next.addResourceBundle(...).init(...) might work, or you could even set the initial data by setting i18next.services.resourceStore.data

jamuhl commented 7 years ago

Would be really great if we could get a small working sample for others to use...if you could share some code (repo, gist) i would be open to have a look.

Would save me a lot of time doing research and starting boilerplating.

TimoRuetten commented 7 years ago

Currently I am working at a boilerplate for a isomorphic reactjs (with express.js) application which is ssr ready. When its finish I will make the repository public so you can see how I will solve it. At this moment I write my own decorator (inspired by yours) and change a few things so it works in my case with react-router v4.

Maybe this will not be the best solution but for my case it will work and of course I can show it to you.

jamuhl commented 7 years ago

great...that's awesome...might even just update the https://react.i18next.com/misc/universal-rendering.html and add a link to your boilerplate in that case

awesome

TimoRuetten commented 7 years ago

I've added for my Boilerplate ssr support for your package. If you want I can show you the Provider and TranslateHOC I've written (hardy inspired by your work) and how I provide the data from server to client.

jamuhl commented 7 years ago

great cool...if anyhow possible i would love to make those changes into the default...will see how big those changes would be

jamuhl commented 7 years ago

@TimoRuetten do you have any link to your boilerplate?

TimoRuetten commented 7 years ago

Hey @jamuhl - sorry I am currently not in the european timezone so I was not anymore in the office. The boilerplate is not finished yet so its private yet but as soon as I am in the office I will send you the code snippet what I have done. At the end there will just be a few changes at your package (some structure changes and a few new props and just a bit of new logic).

TimoRuetten commented 7 years ago

The most important change is that if there is data provided we have to set ready to true.

At the end my code is like yours (it was inspired of your code and based of it) - maybe I have could just change your HOC and Provider but instead I have done a new one which fits in my case. My solution does not have the same setting possibility like yours but for my case its enought.

As far as I think you can easily change your current code to make it also work with react-router v4 (if you want I can do it and can do a PR).

What (I think) would be needed to change:

In my code I set the initalStore as context and the translate-hoc is using it to check if there is a initalStore. My idea was to check if the initialStore provide all needed namespaces (but this is not done yet) and if not that the hoc has to load all not loaded namespaces first before its ready. If you would not need this funcionality you dont have to add the initalStore as context and instead you just set isInitialized after assign the data in the Provider.

This is how the code could look like:

Provider.jsx

import { Component, Children } from 'react';
import PropTypes from 'prop-types';

class I18nProvider extends Component {

  constructor(props) {
    super(props);
    const { initialI18nStore, i18n, lang } = props;
    if (initialI18nStore) {
      Object.assign(i18n.services.resourceStore.data, initialI18nStore);
    }
    if (lang) {
      i18n.changeLanguage(lang);
    }
  }

  getChildContext() {
    const { i18n, initialI18nStore } = this.props;
    return {
      i18n,
      initialI18nStore,
    };
  }

  render() {
    const { children } = this.props;
    return Children.only(children);
  }
}

I18nProvider.propTypes = {
  lang: PropTypes.string,
  i18n: PropTypes.object.isRequired,
  initialI18nStore: PropTypes.object,
  children: PropTypes.element.isRequired,
};

I18nProvider.childContextTypes = {
  i18n: PropTypes.object,
  initialI18nStore: PropTypes.object,
};

export default I18nProvider;

translate.jsx


// Decorator
import React from 'react';
import PropTypes from 'prop-types';

export default (namespaces = [], opt = {}) => (WrapComponent) => {
  const translateFuncName = opt.translateFuncName || 't';
  class TranslateHOC extends React.Component {
    constructor(props, context) {
      super(props, context);

      this.i18n = context.i18n;
      this.initialI18nStore = context.initialI18nStore || false;

      this.options = { ...this.i18n.options.react || {}, ...opt };

      this.state = {
        ready: true, // later false
        _u: 0,
      };

      this.loadNamespaces = this.loadNamespaces.bind(this);
      this.triggerChange = this.triggerChange.bind(this);
      this.bind = this.bind.bind(this);
    }

    getChildContext() {
      return { [translateFuncName]: this[translateFuncName] };
    }

    componentWillMount() {
      this[translateFuncName] =
        this.i18n.getFixedT(null, this.nsMode === 'fallback' ? namespaces : namespaces[0]);
      this.loadNamespaces();
    }

    componentWillUnmount() {
      if (this.binded) {
        this.i18n.off('languageChanged loaded', this.triggerChange);
        this.i18n.store.off('added removed', this.triggerChange);
      }
    }

    bind() {
      if (this.binded) return;
      this.i18n.on('languageChanged loaded', this.triggerChange);
      this.i18n.store.on('added removed', this.triggerChange);
      this.binded = true;
    }

    triggerChange() {
      this.setState({
        _u: Date.now(),
      });
    }

    loadNamespaces() {
      const isInitialized = () => {
        this.bind();
        this.setState({
          ready: true,
        });
      };

                                   // Fix for SSR
      if (this.initialI18nStore || this.i18n.isInitialized) {
        isInitialized();
        return;
      }
      this.i18n.loadNamespaces(namespaces, () => {
        if (this.i18n.isInitialized) {
          isInitialized();
        } else {
          const initialized = () => {
            isInitialized();
            setTimeout(() => { this.i18n.off('initialized', initialized); }, 1000);
          };
          this.i18n.on('initialized', initialized);
        }
      });
    }

    render() {
      const { ready } = this.state;
      const additionalProps = {
        [translateFuncName]: this[translateFuncName],
      };
      return this.options.wait && !ready ? null : (
        <WrapComponent
          {...this.props}
          {...additionalProps}
        />
      );
    }
  }

  TranslateHOC.contextTypes = {
    i18n: PropTypes.object,
    initialI18nStore: PropTypes.object,
  };
  TranslateHOC.childContextTypes = {
    [translateFuncName]: PropTypes.func.isRequired,
  };

  return TranslateHOC;
};

server.jsx


// .... inside of app.get()
  const requestedLanguage = req.language.split('-').shift();
  const requestI18n = i18n.cloneInstance({}, () => {});
  requestI18n.changeLanguage(requestedLanguage);
  // we have to fake that its ready - on server its loaded sync but the initialisation is
  // not sync.
  requestI18n.isInitialized = true;

  const appComponent = (<AppProvider
    location={req.url}
    context={{}}
    store={ssrStore}
    i18n={requestI18n}
  />);

// ...
// ... inside html render function

const htmlString = `
      <!DOCTYPE html>
      <html>
       .....
            <script>
              window.initialI18nStore = JSON.parse(
                '${serialize(requestI18n.services.resourceStore.data, { isJSON: true })}'
              );
              window.detectedLanguage = '${requestedLanguage}';
            </script>
.....
    `;

    // ....

AppProvider.jsx

.....

          <I18nProvider
            i18n={i18nInstance || i18n}
            initialI18nStore={isClient ? window.initialI18nStore : undefined}
            lang={isClient ? window.detectedLanguage : undefined}
          >
            <App />
          </I18nProvider>

.....

This is how I handle it currently and its working fine. I know that my function does not have the same funcitonality of yours so I think I will fork your translate hoc in the next days and implement my changes so it would work for ssr in my case.

Can you see any problems in my code which I maybe does not have seen yet ?

jamuhl commented 7 years ago

great will have a look later - just got up...looking forward to get that into the master branch

jamuhl commented 7 years ago

What happens if you do not return on loadNamespaces?

     if (this.initialI18nStore || this.i18n.isInitialized) {
        isInitialized();
        return;
      }

if that has no negativ impact that should solve reloading a namespace that was not passed by initiali18nStore - as the load functionality checks if stuff is already in the store and would only load the not yet loaded stuff. => But will get a change which triggers a propsChange which would not cause a rerender as content stays the same. But would have to test.

TimoRuetten commented 7 years ago

This has a negative impact because setState() will be called in a callback inside the componentWillMount which causes a react error. But I can easily let setState depending of state.ready so setState will not got called when its still ready.

If ready is false and the setState will get called in a callback it will not cause an error. I am not 100% sure why this happens - but it does. I think it has smth to do that the render function return nothing when ready is false and then its okay to call setState in a callback of componentWillMount.

I will fix it in my code

jamuhl commented 7 years ago

wasn't aware of that...something to look into. thanks for pointing that out.

TimoRuetten commented 7 years ago

Take a look at this: https://stackoverflow.com/questions/40199151/cant-set-state-in-componentwillmount

But as I say its fixable in my example because before setState we can check if ready is still true. If its true we wont do the setState. This should do it.

TimoRuetten commented 7 years ago

I've changed my code and now should it load the namespaces even when its still initialized or has a initial store.

loadNamespaces() {
      const isInitialized = () => {
        this.bind();
        if (!this.state.ready) this.setState({ ready: true });
      };

      if (this.initialI18nStore || this.i18n.isInitialized) {
        isInitialized();
      }
      this.i18n.loadNamespaces(namespaces, () => {
        if (this.i18n.isInitialized) {
          isInitialized();
        } else {
          const initialized = () => {
            isInitialized();
            setTimeout(() => { this.i18n.off('initialized', initialized); }, 1000);
          };
          this.i18n.on('initialized', initialized);
        }
      });
    }
jamuhl commented 7 years ago

awesome....hopefully get on including this tomorrow. currently working hard on some new component doing some big magic ;)

        <Trans i18nKey="transTest" count={count}>
          Hello <strong title={t('nameTitle')}>{{name, format: 'uppercase'}}</strong>, you have {{count}} message. Open <Link to="/msgs">here</Link>.
        </Trans>

will render:

Hello Arthur, you have 10 messages. Open here.

with JSON:

"transTest": "Hello <1><0>{{name}}</0></1>, you have <3>{{count}}</3> message. Open <5>hear</5>.",

jamuhl commented 7 years ago

Will need some more time to get a sample up and figure out all the small changes...had no time yet due other tasks. If you would like providing a PR with sample and needed changes would help - but i'm aware you got enough other work todo on your boilerplate and work.

jamuhl commented 7 years ago

@TimoRuetten I just took time to create a sample using razzle https://github.com/jaredpalmer/razzle

Made the adaption to provider. So far so good the only tricky part i run into was for the initial clientside render after picking up serverside render the wait flag has to be set to false to avoid the translate hoc to return null. -> But only on that first render.

The solution i came up for now is:

provider in constructor:

    if (props.initialI18nStore) {
      Object.assign(this.i18n.services.resourceStore.data, props.initialI18nStore);
      this.i18n.options.isInitialSSR = true;
      setTimeout(function () {
        delete this.i18n.options.isInitialSSR;
      }, 1000);
    }

and in translate hoc:

        if (this.i18n.options.isInitialSSR) {
          wait = false;
        }

Works but the solution looks rather hacky. Any better idea to solve this?

jamuhl commented 7 years ago

Ok i think i found a solution i will remove the flag after first pass of rendering in hoc (wait was false and ready true)

TimoRuetten commented 7 years ago

Hey Jan! Sorry currently I am not rly available because I am out of city and here we don't have that much internet here (in the philippines its rare!). In 1.5 weeks I will be back in Europe then I can do a PR if its necessary anymore.

Btw.: In my boilerplate is a simple helper variable which tells if the first render already finished ( componentDidMount)

jamuhl commented 7 years ago

ok so similar approach...will do some clean up and publish a new version - from there you might have a look and add a PR if still something missing to make your boilerplate work with the current codebase...

thanks a lot for the help.

jamuhl commented 7 years ago

https://github.com/i18next/react-i18next/commit/a528d56b450de137935b3ac686f5e8d45f321743

sample: https://github.com/i18next/react-i18next/tree/master/example/razzle-ssr

published with react-i18next@4.6.0