i18next / i18next-http-backend

i18next-http-backend is a backend layer for i18next using in Node.js, in the browser and for Deno.
MIT License
454 stars 70 forks source link

No error is thrown if the translation file download fails #63

Closed Tubaleviao closed 3 years ago

Tubaleviao commented 3 years ago

🐛 Bug Report

I installed this library to download the translation files as needed (lazy loading), inside my project. If the file is not in the server or the server is offline for some reason, I want to get whatever error happened and show it nicely to the user.

The main problem happens when the file is not in the server or the server is offline for some reason. This library simply throw some warnings on the console.log and change the language anyway (even if the file was not downloaded), like so:

image

As a default behavior, I'd expect the i18n.changeLanguage Promise to throw an error so I can treat it inside a try/catch block function. Or maybe an extra configuration key called "ifFileNotDownloadedChangeLanguageAnyway" so we can prevent the language to be changed if the download fails.

To Reproduce

The installation:

npx create-react-app pig --template typescript
cd pig

Then install the libraries:

npm i react-i18next i18next i18next-http-backend @material-ui/core

My i18n.ts file:

import i18n from "i18next";
import { initReactI18next } from "react-i18next";
import Backend from 'i18next-http-backend'

i18n
  .use(initReactI18next)
  .use(Backend)
  .init({
    backend:{
      loadPath: '/translations/{{lng}}.json'
    },
    react:{useSuspense:false},
    debug: true,
    lng: "en",
    keySeparator: false,
    interpolation: {
      escapeValue: false
    }
  });

  export default i18n;

My index.tsx:

import React from 'react';
import ReactDOM from 'react-dom';
import './index.css';
import './i18n';
import App from './App';
import reportWebVitals from './reportWebVitals';

ReactDOM.render(
    <App />,
  document.getElementById('root')
);

reportWebVitals();

My huge App.tsx:

import { Backdrop, CircularProgress, Button } from "@material-ui/core"
import { makeStyles } from '@material-ui/core/styles';
import './App.css';
import { useTranslation } from 'react-i18next';
import { useState } from "react"

const useStyles = makeStyles((theme) => ({
  backdrop: {
    zIndex: theme.zIndex.drawer + 1,
    color: '#fff',
  },
}));

function App() {
  const [lang, setLang] = useState("en");
  const [block, setBlock] = useState(false);
  const { t, i18n, ready } = useTranslation();
  const handle = async () => {
    try{
      setBlock(true)
      const newLang = lang==="en"?"pt":"en"
      await i18n.changeLanguage(newLang)
      setLang(newLang)
      setBlock(false)
    }catch(e){
      console.log("this is never called", e)
    }
  }
  const classes = useStyles();
  return (
    <div className="App">
      <Backdrop className={classes.backdrop} open={block || !ready}>
        <CircularProgress/>
      </Backdrop>
      {ready && (
        <>
          <h1 onClick={handle}>{t("pig")}</h1>
          <Button onClick={handle} >Click me</Button>
        </>
      )}

    </div>
  );
}

export default App;

My folder structure/translation files:

image

Then you can simply exclude the pt.json file or block the requests to this file in the chrome dev tools > Network tab and click the "Click me" button.

My Environment

adrai commented 3 years ago

You may want to use some sort of fallback with the help of i18next-chaned-backend: https://www.i18next.com/how-to/caching

btw: there is also an onFailedLoading event: https://www.i18next.com/overview/api#onfailedloading

jamuhl commented 3 years ago
YarnSphere commented 3 years ago

Funnily enough, I was just trying to figure out how this works with React too.

Using suspense, I was expecting the promise to reject if a resource could not be fetched (I'm not using a fallbackLng nor do I want to), and I could then use a React error boundary to catch it. But the page just renders without the translations, showing "translation keys" everywhere (imo, this should never happen).

I'll see what I can do with the onFailedLoading event, but I still think that it is odd that no error is propagated from the suspense when the fetching fails.

YarnSphere commented 3 years ago

Sorry to post again on this issue, but I think it's related and I am yet to find a solution. How would you handle the following scenario?

From reading the docs, it seems like i18next provides a reloadResources function that I could use for the "retry". However, I currently see no way of detecting that a resource failed to load exactly because no error is being thrown. Instead, the page just loads with a bunch of "missing keys" as @Tubaleviao reported.

I can (and probably will), as @adrai mentioned, add i18next-chained-backend together with local storage caching to minimise the chance of a resource not loading, but the possibility still exists if the connection drops before loading a page for the first time.

Note that if an error was thrown, I could easily catch it within an error boundary and apply the logic that I'm describing here. Especially if the error had the information of exactly what translation resource failed to load (language and namespace).

jamuhl commented 3 years ago

@nunocastromartins did you ever checked how suspense works? It's a thrown promise that resolves when ready.

In the end all is the same as when your API endpoint fails for loading data -> what do you do...?

You can create some custom useTranslation that acts different...our implementation is made to get out of the way...and render fallback...if your app needs to access those translations...you might add some healthcheck to it...and act as needed if it fails.

YarnSphere commented 3 years ago

@nunocastromartins did you ever checked how suspense works? It's a thrown promise that resolves when ready.

I might be wrong here, but can't the thrown promise reject if the resource failed to load, instead of resolving? See: https://reactjs.org/docs/concurrent-mode-suspense.html#handling-errors and https://chan.dev/posts/catch-your-suspense-errors

In my opinion, you should reject the promise instead of resolving it in this case. In order to prevent a breaking change, it could at least be a configuration option: rejectOnFailedLoading, or similar (would this need to be implemented in react-i18next?).

In the end all is the same as when your API endpoint fails for loading data -> what do you do...?

The point here is that I am able to detect that I failed to load data; this is where I'm stuck, how do I detect that a translation resource failed to load when using suspense?

jamuhl commented 3 years ago

because

either it is loading -> suspense is thrown or it loaded / or gave up after retries

if your webapp and translations are running on the same server nothing will run anyway. if you run multiple instances / microservices reload mechanism in i18next should be enough.

but feel free to add a PR

Tubaleviao commented 3 years ago

Thanks for the reply guys! As @jamuhl and @adrai pointed, I was able to make it work after spending a while discovering how those events and callbacks behave.

The code got a little big since we don't have a library that throws an error when an error occurs, but now it works!

@nunocastromartins I'll paste the code here with some comments, in case it helps you:

The i18n.ts file:

import i18n from "i18next";
import { initReactI18next } from "react-i18next";
import Backend from 'i18next-http-backend'

i18n
  .use(initReactI18next)
  .use(Backend)
  .init({
    backend:{
      loadPath: '/translations/{{lng}}.json'
    },
    react:{useSuspense:false},
    debug: true,
    lng: "en",
    fallbackLng: "dev",
    keySeparator: false,
    interpolation: {
      escapeValue: false
    }
  });

  export default i18n;

The App.tsx file:

import { Backdrop, CircularProgress, Button } from "@material-ui/core"
import { makeStyles } from '@material-ui/core/styles';
import './App.css';
import { useTranslation } from 'react-i18next';
import { useState } from "react"
import { Subject, interval  } from 'rxjs';
import { debounce } from 'rxjs/operators';

const useStyles = makeStyles((theme) => ({
  backdrop: {
    zIndex: theme.zIndex.drawer + 1,
    color: '#fff',
  },
}));

const fail = new Subject<{ lang: any; i18n: any; msg:string; setError:any; setLoading:any;}>()
const lastFail = fail.pipe(debounce(() => interval(100)))
lastFail.subscribe(({lang, i18n, msg, setError, setLoading}) => {
  // msg this is the message of the error!
  setError(msg)
  setLoading(false)
  i18n.changeLanguage(lang)
})

function App() {
  const [lang, setLang] = useState("en");
  const [error, setError] = useState("");
  const [loading, setLoading] = useState(false);
  const { t, i18n, ready } = useTranslation();

  i18n.on('failedLoading', (lng,ns,msg) => {
    // failedLoading is called multiple times for the same error
    // so we use a debounce operator to get just the last one
    if(lng !== lang) fail.next({lang, i18n, msg, setError, setLoading})
  })

  const handle = async () => {
    setError('')
    setLoading(true)
    const newLang = lang==="en"?"pt":"en"
    i18n.changeLanguage(newLang, async (err, t) => {
      if(err){ 
        // since the fallbackLng:dev has no translations, go back to en
        i18n.changeLanguage(lang)
      } else {
        // even if err is null, we test if it's using the falbackLng
        if(!error && t('pig') !== 'pig') {
          // if we have some translation, we are done!
          setLang(newLang)
          setLoading(false)
        } else {
          // this means the resource download failed and we are on falbackLgn
          // now every click on the button will lead to here, so we try downloading again
          await i18n.reloadResources(newLang, "translation", () => {
            // we check if we have some translation here, if we do we are done!
            if(t('pig') !== 'pig') setLang(newLang)
          });
          setLoading(false)
        }
      }
    })
  }

  const classes = useStyles();
  return (
    <div className="App">
      <Backdrop className={classes.backdrop} open={loading || !ready}>
        <CircularProgress/>
      </Backdrop>
      {ready && (
        <>
          <h1>{t("pig")}</h1>
          <Button onClick={handle} >Click me</Button>
          <h1>{lang}</h1>
          <h2>{error}</h2>
        </>
      )}

    </div>
  );
}

export default App;

And then I created an empty json inside my dev.json file, in the translations folder.