tr3online
tr3online

Reputation: 1429

Hydrating SSR React App with react-i18next causes flicker

I've been trying to get SSR to work with react-i18next for quite some time, the documentation is somewhat lacking so I've pieced together what I could from some other repos and their razzle-ssr example.

I have the server-side working, where I:

  1. Setup express, call the appropriate middleware, get the appropriate locale:
const app = express();
await i18n
    .use(Backend)
    .use(i18nextMiddleware.LanguageDetector)
    .init(options)

app.use(i18nextMiddleware.handle(i18n));
app.use('/locales', express.static(`${appDirectory}/locales`));
  1. Get the DOM representation of the App given the request:
app.get('/*', req => {
    //...
    const html = ReactDOMServer.renderToString(
        <I18nextProvider i18n={req.i18n}>>
            <App />
        </I18nextProvider>
    )
    // ...
})
  1. Append the initialI18nStore to the request content:
const initialI18nStore = {};
req.i18n.languages.forEach(l => {
  initialI18nStore[l] = req.i18n.services.resourceStore.data[l];
});
const initialLanguage = req.i18n.language;

content = content.replace(
/<head>/,
    `<head>
        <script>
        window.initialI18nStore = "${JSON.stringify(initialI18nStore)}";
        window.initialLanguage = "${initialLanguage.slice(0, 2)}";
    </script>`,
);

This works great when I curl http://localhost:3000/ I get the correct DOM with the loaded/replaced translations

The problem I encounter is hydration.

I tried using useSSR with Suspense but couldn't seem to get it working. But I feel that will have fundamentally the same problem: i18n needs to initialize with languages before we should hydrate the app. Correct(?)

I attempted to emulate the same thing as useSSR by waiting for the client i18n instance to be initialized before hydrating the app:

// client i18n.js
import i18n from 'i18next';
import { initReactI18next } from 'react-i18next';
import Backend from 'i18next-http-backend';
import LanguageDetector from 'i18next-browser-languagedetector';

i18n
  .use(initReactI18next)
  .use(Backend)
  .use(LanguageDetector);

const i18nInit = () => {
  return new Promise(resolve => {
    // @todo: shim in moment locale here
    i18n.init(options, () => resolve(i18n));
  });
};

export default i18nInit;
// client index.js
const renderApp = async () => {
  let i18n = await i18ninit();
  if (window.initialI18nStore) {
    i18n.services.resourceStore.data = window.initialI18nStore;
  }
  hydrate(<BaseApp />, document.getElementById('root'));
};

renderApp();

The problem with this is: The app renders fine from the server-provided DOM representation. Then when I wait for the client i18n instance to initialize then hydrate the app, I get a huge style-less flicker, then it returns the same view as the DOM representation.

I also tried to do the deferred rendering inside of a functional component:

const BaseApp = () => {
    const [render, setRender] = useState(false);
    useEffect( () => {
        await initI18();
        i18n.services.resourceStore.data = INITIALI18NSTORE;
        setRender(true);
    }, [])
    if(!render) return null;
    return <App />
}

But this causes similar, but instead of a style-less flicker, a white screen due to return null.

Is there a concept I'm missing here? Or am I doing something out of order? How do I get a seamless transition from the server provided DOM+styles to my client provided ones with translations included?

Upvotes: 4

Views: 6711

Answers (1)

Rick Chen
Rick Chen

Reputation: 21

I have tried to reproduce your problem. At the step you mentioned:

I attempted to emulate the same thing as useSSR by waiting for the client i18n instance to be initialized before hydrating the app:

At this step, I found that the SSR result is difference from the CSR result: SSR vs CSR

It was because my language is zh-TW, and no 'fallbackLng' provided on the server-side.

I added this line as the i18n.js did, and solved the problem

// server.js
i18n
    .use(Backend)
    .use(i18nextMiddleware.LanguageDetector)
    .init(
      {
        debug: false,
        preload: ['en', 'de'],
        fallbackLng: 'en', // << ----- this line
        ns: ['translations'],
        defaultNS: 'translations',
        backend: {
          loadPath: `${appSrc}/locales/{{lng}}/{{ns}}.json`,
          addPath: `${appSrc}/locales/{{lng}}/{{ns}}.missing.json`,
        },
      },
...
...
...

To make sure that client renders the correct DOMs just at the first time, I set the useSuspense to false and remove the <Suspense> component

// i18n.js
const options = {
  fallbackLng: 'en',
  load: 'languageOnly', // we only provide en, de -> no region specific locals like en-US, de-DE
  // have a common namespace used around the full app
  ns: ['translations'],
  defaultNS: 'translations',

  saveMissing: true,
  debug: true,

  interpolation: {
    escapeValue: false, // not needed for react!!
    formatSeparator: ',',
    format: (value, format, lng) => {
      if (format === 'uppercase') return value.toUpperCase();
      return value;
    },
  },
  react: {
    useSuspense: false,  // << ----- this line
  },
  wait: process && !process.release,
};


// client.js
const BaseApp = () => {
  useSSR(window.initialI18nStore, window.initialLanguage);
  return (
      <BrowserRouter>
        <App />
      </BrowserRouter>
  );
}

And everything works fine

Upvotes: 2

Related Questions