Per Locale

in React

Tyler Graf

Web Dev

Tree

πŸ‘¨πŸ»

Goals

  1. Help you understand our react stack
  2. Dip your toe into webpack
  3. Upgrade your app to per-locale

Current Issue

We load all locale strings on every page

zh

bg fr lt sk zh
cs hr lv sl zh-hans
da ht mk sm
de hu mn sq
el hy ms sr
en id nl sv
eo is no th
es it pl to
et ja pt tr
fi km ro uk
fj ko ru vi

@fs/zion-ui

en 1KB gzip

all

~45KB gzip

i18next

import i18n from "i18next";
import {initReactI18next} from 'react-i18next'
import localeStrings from './allLocaleStrings.json'

i18n
  .use(initReactI18next)
  .init({
    lng: "zh",
    fallbackLng: "en",
    resources: localeStrings
  });
import React from 'react'
import './i18n'
import { useTranslation } from "react-i18next";

export default function App(){
  const { t } = useTranslation();
  return <div>{t("title")}</div>
}
i18n.js
App.js
import i18n from "i18next";

export const addTranslations = (translations) => {
  translations.forEach(locale=>{
    i18n.addResources(locale, 'translation', translations[locale])
  })
}

// ... lots of other stuff too
import React from 'react'
import { addTranslations } from '@fs/zion-locale'
import { useTranslation } from "react-i18next";
import translations from './locales'

addTranslations(translations)

export default function App(){
  const { t } = useTranslation();
  return <div>{t("title")}</div>
}
@fs/zion-locale
App.js

alienfast/i18next-loader

// App.js
import React from "react";
import translations from "./locales";
import { addTranslations } from "@fs/zion-locale";
import { useTranslation } from "react-i18next";

addTranslations(translations);

export default function App() {
  const [t] = useTranslation();
  return (
    <div>
      <h1>{t("overview")}</h1>
    </div>
  );
}
{
  "overview": "Overview"
}
// intentionally left blank
.
β”œβ”€β”€ App.js
β”œβ”€β”€ node_modules
β”‚   └── @fs
β”‚       └── zion-locale
β”‚           └── locales
β”‚               β”œβ”€β”€ de
β”‚               β”‚   β”œβ”€β”€ common-ui.json
β”‚               β”‚   └── translation.json
β”‚               β”œβ”€β”€ en
β”‚               β”‚   β”œβ”€β”€ common-ui.json
β”‚               β”‚   └── translation.json
β”‚               β”œβ”€β”€ es
β”‚               β”‚   β”œβ”€β”€ common-ui.json
β”‚               β”‚   └── translation.json
β”‚               └── index.js
└── locales
    β”œβ”€β”€ de
    β”‚   └── translation.json
    β”œβ”€β”€ en
    β”‚   └── translation.json
    β”œβ”€β”€ es
    β”‚   └── translation.json
    └── index.js

export default {
  de: {
    overview: "Überblick"
  },
  en: {
    overview: "Overview"
  },
  es: {
    overview: "VisiΓ³n general"
  }
};
// intentionally left blank
.
β”œβ”€β”€ App.js
β”œβ”€β”€ node_modules
β”‚   └── @fs
β”‚       └── zion-locale
β”‚           └── locales
β”‚               β”œβ”€β”€ de
β”‚               β”‚   β”œβ”€β”€ common-ui.json
β”‚               β”‚   └── translation.json
β”‚               β”œβ”€β”€ en
β”‚               β”‚   β”œβ”€β”€ common-ui.json
β”‚               β”‚   └── translation.json
β”‚               β”œβ”€β”€ es
β”‚               β”‚   β”œβ”€β”€ common-ui.json
β”‚               β”‚   └── translation.json
β”‚               └── index.js
└── locales
    β”œβ”€β”€ de
    β”‚   └── translation.json
    β”œβ”€β”€ en
    β”‚   └── translation.json
    β”œβ”€β”€ es
    β”‚   └── translation.json
    └── index.js

export default {
  de: {
    overview: "Überblick"
  },
  en: {
    overview: "Overview"
  },
  es: {
    overview: "VisiΓ³n general"
  }
};
// App.js
import React from "react";
import translations from "./locales";
import { addTranslations } from "@fs/zion-locale";
import { useTranslation } from "react-i18next";

addTranslations(translations);

export default function App() {
  const [t] = useTranslation();
  return (
    <div>
      <h1>{t("overview")}</h1>
    </div>
  );
}

How do we solve this?

Community!!

http-backend

dynamic import

Webpack refresher

(It's a module bundler)

What's a module bundler?

add.js

index.js

subtract.js

bundle.js

import add from './add.js';
import subtract from './subtract.js';

console.log(`Add: ${add(4,5)}`);
console.log(`Subtract: ${subtract(9,5)}`);
const add = (a, b) => {
  return a + b;
}

export default add;
const subtract = (a, b) => {
  return a - b;
}

export default subtract;

index.js

add.js

subtract.js

const add = (a, b) => {
  return a + b;
}

const subtract = (a, b) => {
  return a - b;
}

console.log(`Add: ${add(4,5)}`);
console.log(`Subtract: ${subtract(9,5)}`);

bundle.js

Well, kinda

/*
 * ATTENTION: The "eval" devtool has been used (maybe by default in mode: "development").
 * This devtool is neither made for production nor for readable output files.
 * It uses "eval()" calls to create a separate source file in the browser devtools.
 * If you are trying to read the output file, select a different devtool (https://webpack.js.org/configuration/devtool/)
 * or disable the default devtool with "devtool: false".
 * If you are looking for production-ready output files, see mode: "production" (https://webpack.js.org/configuration/mode/).
 */
/******/ (() => { // webpackBootstrap
/******/ 	"use strict";
/******/ 	var __webpack_modules__ = ({

/***/ "./src/add.js":
/*!********************!*\
  !*** ./src/add.js ***!
  \********************/
/***/ ((__unused_webpack_module, __webpack_exports__, __webpack_require__) => {

eval("__webpack_require__.r(__webpack_exports__);\n/* harmony export */ __webpack_require__.d(__webpack_exports__, {\n/* harmony export */   \"default\": () => /* binding */ add\n/* harmony export */ });\nfunction add(a, b) {\n  return a + b;\n}\n\n\n//# sourceURL=webpack://basic-webpack/./src/add.js?");

/***/ }),

/***/ "./src/index.js":
/*!**********************!*\
  !*** ./src/index.js ***!
  \**********************/
/***/ ((__unused_webpack_module, __webpack_exports__, __webpack_require__) => {

eval("__webpack_require__.r(__webpack_exports__);\n/* harmony import */ var _subtract__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(/*! ./subtract */ \"./src/subtract.js\");\n/* harmony import */ var _add__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(/*! ./add */ \"./src/add.js\");\n\n\n\nconsole.log((0,_subtract__WEBPACK_IMPORTED_MODULE_0__.default)(10,4))\nconsole.log((0,_add__WEBPACK_IMPORTED_MODULE_1__.default)(10,4))\n\n\n//# sourceURL=webpack://basic-webpack/./src/index.js?");

/***/ }),

/***/ "./src/subtract.js":
/*!*************************!*\
  !*** ./src/subtract.js ***!
  \*************************/
/***/ ((__unused_webpack_module, __webpack_exports__, __webpack_require__) => {

eval("__webpack_require__.r(__webpack_exports__);\n/* harmony export */ __webpack_require__.d(__webpack_exports__, {\n/* harmony export */   \"default\": () => /* binding */ subtract\n/* harmony export */ });\nfunction subtract(a, b) {\n  return a - b;\n}\n\n\n//# sourceURL=webpack://basic-webpack/./src/subtract.js?");

/***/ })

/******/ 	});
/************************************************************************/
/******/ 	// The module cache
/******/ 	var __webpack_module_cache__ = {};
/******/ 	
/******/ 	// The require function
/******/ 	function __webpack_require__(moduleId) {
/******/ 		// Check if module is in cache
/******/ 		if(__webpack_module_cache__[moduleId]) {
/******/ 			return __webpack_module_cache__[moduleId].exports;
/******/ 		}
/******/ 		// Create a new module (and put it into the cache)
/******/ 		var module = __webpack_module_cache__[moduleId] = {
/******/ 			// no module.id needed
/******/ 			// no module.loaded needed
/******/ 			exports: {}
/******/ 		};
/******/ 	
/******/ 		// Execute the module function
/******/ 		__webpack_modules__[moduleId](module, module.exports, __webpack_require__);
/******/ 	
/******/ 		// Return the exports of the module
/******/ 		return module.exports;
/******/ 	}
/******/ 	
/************************************************************************/
/******/ 	/* webpack/runtime/define property getters */
/******/ 	(() => {
/******/ 		// define getter functions for harmony exports
/******/ 		__webpack_require__.d = (exports, definition) => {
/******/ 			for(var key in definition) {
/******/ 				if(__webpack_require__.o(definition, key) && !__webpack_require__.o(exports, key)) {
/******/ 					Object.defineProperty(exports, key, { enumerable: true, get: definition[key] });
/******/ 				}
/******/ 			}
/******/ 		};
/******/ 	})();
/******/ 	
/******/ 	/* webpack/runtime/hasOwnProperty shorthand */
/******/ 	(() => {
/******/ 		__webpack_require__.o = (obj, prop) => Object.prototype.hasOwnProperty.call(obj, prop)
/******/ 	})();
/******/ 	
/******/ 	/* webpack/runtime/make namespace object */
/******/ 	(() => {
/******/ 		// define __esModule on exports
/******/ 		__webpack_require__.r = (exports) => {
/******/ 			if(typeof Symbol !== 'undefined' && Symbol.toStringTag) {
/******/ 				Object.defineProperty(exports, Symbol.toStringTag, { value: 'Module' });
/******/ 			}
/******/ 			Object.defineProperty(exports, '__esModule', { value: true });
/******/ 		};
/******/ 	})();
/******/ 	
/************************************************************************/
/******/ 	// startup
/******/ 	// Load entry module
/******/ 	__webpack_require__("./src/index.js");
/******/ 	// This entry module used 'exports' so it can't be inlined
/******/ })()
;

bundle.js

module.exports = {
  entry: './src/index.js',
  output: {
    path: '/dist',
    filename: 'bundle.js'
  }
}

Basic Config

Demo

Webpack Loaders

module.exports = {
  entry: './src/index.js',
  output: {
    path: '/dist',
    filename: 'bundle.js'
  }
}
module.exports = {
  entry: './src/index.js',
  output: {
    path: '/dist',
    filename: 'bundle.js'
  },
  module: {
    rules: [
      {
        test: /\.js$/,
        use: {
          loader: 'my-replace-loader'
        }
      }
    ]
  }
}

Loaders take a string and return a string

module.exports = function(source) {
  
  source = source.replace(/stuff/g, "things");
  
  return source;
};
items.map(item=>{

  return item.replace('stuff','thing');

});

Remind's me of a map function

my-replace-loader

export default function(){
  const stuff = 'stuff'

  console.log(stuff)
}
export default function(){
  const things = 'things'

  console.log(things)
}

add.js

index.js

subtract.js

bundle.js

my-replace-loader

once for each file

Demo

alienfast/i18next-loader

module.exports = {
  entry: './src/index.js',
  output: {
    path: '/dist',
    filename: 'bundle.js'
  },
  module: {
    rules: [
      {
        test: /locales/,
        use: {
          loader: 'alienfast/i18next-loader'
        }
      }
    ]
  }
}
export default {
  de: {
    overview: "Überblick"
  },
  en: {
    overview: "Overview"
  },
  es: {
    overview: "VisiΓ³n general"
  }
};
// intentionally left blank
.
β”œβ”€β”€ App.js
β”œβ”€β”€ node_modules
β”‚   └── @fs
β”‚       └── zion-locale
β”‚           └── locales
β”‚               β”œβ”€β”€ de
β”‚               β”‚   β”œβ”€β”€ common-ui.json
β”‚               β”‚   └── translation.json
β”‚               β”œβ”€β”€ en
β”‚               β”‚   β”œβ”€β”€ common-ui.json
β”‚               β”‚   └── translation.json
β”‚               β”œβ”€β”€ es
β”‚               β”‚   β”œβ”€β”€ common-ui.json
β”‚               β”‚   └── translation.json
β”‚               └── index.js
└── locales
    β”œβ”€β”€ de
    β”‚   └── translation.json
    β”œβ”€β”€ en
    β”‚   └── translation.json
    β”œβ”€β”€ es
    β”‚   └── translation.json
    └── index.js

alienfast/i18next-loader

const jsonfile = require("jsonfile");

module.exports = function() {
  const allLocales = getDirectoryNames();
  const bundle = {};

  //['en','de','es'].forEach
  allLocales.forEach(locale => {
    bundle[locale] = jsonfile.readFileSync(`./${locale}/translation.json`);
  });

  return "export default " + JSON.stringify(bundle);
};
.
β”œβ”€β”€ App.js
β”œβ”€β”€ node_modules
β”‚   └── @fs
β”‚       └── zion-locale
β”‚           └── locales
β”‚               β”œβ”€β”€ de
β”‚               β”‚   β”œβ”€β”€ common-ui.json
β”‚               β”‚   └── translation.json
β”‚               β”œβ”€β”€ en
β”‚               β”‚   β”œβ”€β”€ common-ui.json
β”‚               β”‚   └── translation.json
β”‚               β”œβ”€β”€ es
β”‚               β”‚   β”œβ”€β”€ common-ui.json
β”‚               β”‚   └── translation.json
β”‚               └── index.js
└── locales
    β”œβ”€β”€ de
    β”‚   └── translation.json
    β”œβ”€β”€ en
    β”‚   └── translation.json
    β”œβ”€β”€ es
    β”‚   └── translation.json
    └── index.js

export default {
  de: {
    overview: "Überblick"
  },
  en: {
    overview: "Overview"
  },
  es: {
    overview: "VisiΓ³n general"
  }
};

alienfast/i18next-loader.js

Dynamic Import

export default {
  de: {
    overview: "Überblick"
  },
  en: {
    overview: "Overview"
  },
  es: {
    overview: "VisiΓ³n general"
  }
};
import i18n from 'i18next'

import(`./locales/${i18n.language}/translation.json`)
  .then(localeStrings => {
    i18n.addResources(i18n.language, 'translation', localeStrings)
  })

new

old

import i18n from 'i18next'

import(`./locales/${i18n.language}/translation.json`)
  .then(localeStrings => {
    i18n.addResources(i18n.language, 'translation', localeStrings)
  })
.
β”œβ”€β”€ App.js
β”œβ”€β”€ node_modules
β”‚   └── @fs
β”‚       └── zion-locale
β”‚           └── locales
β”‚               β”œβ”€β”€ de
β”‚               β”‚   β”œβ”€β”€ common-ui.json
β”‚               β”‚   └── translation.json
β”‚               β”œβ”€β”€ en
β”‚               β”‚   β”œβ”€β”€ common-ui.json
β”‚               β”‚   └── translation.json
β”‚               β”œβ”€β”€ es
β”‚               β”‚   β”œβ”€β”€ common-ui.json
β”‚               β”‚   └── translation.json
β”‚               └── index.js
└── locales
    β”œβ”€β”€ de
    β”‚   └── translation.json
    β”œβ”€β”€ en
    β”‚   └── translation.json
    β”œβ”€β”€ es
    β”‚   └── translation.json
    └── index.js

Will this even work?

module.exports = function() {
  return `
import i18n from 'i18next'

import(\`./locales/${i18n.language}/translation.json\`)
  .then(localeStrings => {
    i18n.addResources(i18n.language, 'translation', localeStrings)
  })
`
};

Loader

// intentionally left blank

dynamic-import-loader

// App.js
import React from "react";
import "./locales";
import { useTranslation } from "react-i18next";

export default function App() {
  const [t] = useTranslation();
  return (
    <div>
      <h1>{t("overview")}</h1>
    </div>
  );
}
// App.js
import React from "react";
import /*translations from*/ "./locales";
// import { addTranslations } from "@fs/zion-locale";
import { useTranslation } from "react-i18next";

// addTranslations(translations);

export default function App() {
  const [t] = useTranslation();
  return (
    <div>
      <h1>{t("overview")}</h1>
    </div>
  );
}
// App.js
import React from "react";
import translations from "./locales";
import { addTranslations } from "@fs/zion-locale";
import { useTranslation } from "react-i18next";

addTranslations(translations);

export default function App() {
  const [t] = useTranslation();
  return (
    <div>
      <h1>{t("overview")}</h1>
    </div>
  );
}

i18next-http-backend

.
β”œβ”€β”€ App.js
└── locales
 Β Β  β”œβ”€β”€ de
 Β Β  β”‚   └── translation.json
 Β Β  β”œβ”€β”€ en
 Β Β  β”‚   └── translation.json
 Β Β  β”œβ”€β”€ es
 Β Β  β”‚   └── translation.json
 Β Β  └── index.js
// App.js
import React from "react";
import /*translations from*/ "./locales";
// import { addTranslations } from "@fs/zion-locale";
import { useTranslation } from "react-i18next";

// addTranslations(translations);

export default function App() {
  const [t] = useTranslation();
  return (
    <div>
      <h1>{t("overview")}</h1>
    </div>
  );
}
{
  "overview": "Overview"
}
// intentionally left blank
// App.js
import React from "react";
import translations from "./locales";
import { addTranslations } from "@fs/zion-locale";
import { useTranslation } from "react-i18next";

addTranslations(translations);

export default function App() {
  const [t] = useTranslation();
  return (
    <div>
      <h1>{t("overview")}</h1>
    </div>
  );
}
// https://edge.fscdn.org/assets/static/locales/en/translation-1h4l12412m.json
{
  "overview": "Overview",
  "ok": "OK",
  "stuff": "Stuff",
}
.
β”œβ”€β”€ App.js
β”œβ”€β”€ node_modules
β”‚   └── @fs
β”‚       └── zion-locale
β”‚           └── locales
β”‚               β”œβ”€β”€ de
β”‚               β”‚   β”œβ”€β”€ common-ui.json
β”‚               β”‚   └── translation.json
β”‚               β”œβ”€β”€ en
β”‚               β”‚   β”œβ”€β”€ common-ui.json
β”‚               β”‚   └── translation.json
β”‚               β”œβ”€β”€ es
β”‚               β”‚   β”œβ”€β”€ common-ui.json
β”‚               β”‚   └── translation.json
β”‚               └── index.js
└── locales
    β”œβ”€β”€ de
    β”‚   └── translation.json
    β”œβ”€β”€ en
    β”‚   └── translation.json
    β”œβ”€β”€ es
    β”‚   └── translation.json
    └── index.js

// intentionally left blank
// https://edge.fscdn.org/assets/static/locales/en/common-ui-123irnwelk.json
{
  "common.things": "Common Things",
  "cool": "Super cool"
}
module.exports = {}
// index.html
<script>
window.localeManifest = {
  'en-translation': 'https://edge.fscdn.org/assets/static/locales/en/translation-1h4l12412m.json',
  'en-common-ui': 'https://edge.fscdn.org/assets/static/locales/en/common-io-mmklk3i23.json',
}
</script>
<script src="https://edge.fscdn.org/assets/static/js/main-sdfisdg9e.js">
// @fs/zion-locale

import i18n from "i18next"
import Backend from "i18next-http-backend";

i18n
  .use(Backend)
  .init({
    backend: {
      loadPath: (locale, ns) => window.localeManifest?.[`${locale}-${ns}`]
    }
  });

Pros/Cons

http-backend

Pros

  • Used by the community
  • fewer requests
  • Shared common-ui cache across apps

Cons

  • Major bump to zion-locale

  • need to figure out a way to load locales in storybook

  • could be loading unused locale strings

  • issue with json file loading slowly/uncompressed from cdn
  • Not backward compatible

Pros

  • 100% backward compatible
  • smaller payload size
  • loads faster on slower networks
  • webpack handles loading

Cons

  • our own implementation

  • more requests

dynamic import

Adoption

  • major bump @fs/react-scripts
  • major bump @fs/zion-locale
  • Add a <Suspense fallback="" /> as top level component

Β 

  • zion storybook will have to inject the manifest somehow
  • zion tests may need to change

http-backend

  • major bump @fs/react-scripts@6.0.0
  • be on @fs/zion-locale@3.4.0

dynamic-import

Perfomance

Temple R9

alienfast/i18next-loader

i18next-http-backendΒ  Β  Β  Β  788KB (27% smaller)

dynamic importΒ  Β  618KB (43% smaller)

1086KB

Changelog

21% smaller

1284KB

1012KB

Merge

22% smaller

1240KB

967KB

Overview

11% smaller

1247KB

1106KB

Code

How can I test it?

npm i @fs/react-scripts@6.0.0-beta.6
npm i @fs/zion-locale@3.4.0-beta.0

Thanks guys

Per-locale presentation

By Tyler Graf

Per-locale presentation

  • 728