Repository: lingui/js-lingui
Pull request #513 (2019-06-01 12:31:06)

Refactor active/load events


Patch:
diff --git a/.circleci/config.yml b/.circleci/config.yml
index 7913da4b..d8912939 100644
--- a/.circleci/config.yml
+++ b/.circleci/config.yml
@@ -124,7 +124,7 @@ jobs:
 
   build-docs:
     docker:
-      - image: circleci/python:3.6
+      - image: circleci/python:3.7-node
         environment:
           PYGMENTS_NODE_COMMAND: /usr/local/bin/node
 
@@ -141,10 +141,13 @@ jobs:
           paths:
               - "{{ pipenv --venv }}"
 
+      - run: yarn global add now
+
       - run: cd docs; pipenv run make html
 
       - deploy:
-          command: ./scripts/legacy/deploy-docs.sh
+          command: $(yarn global bin)/now --public --token $NOW_TOKEN --name lingui-docs docs/_build/html
+#          command: ./scripts/legacy/deploy-docs.sh
 
 workflows:
   version: 2
@@ -153,7 +156,7 @@ workflows:
 #      - build-docs:
 #          filters:
 #            branches:
-#              only:
+#              ignore:
 #                - master
 
       - build
diff --git a/Pipfile b/Pipfile
index 6ee17e7a..d22eb53b 100644
--- a/Pipfile
+++ b/Pipfile
@@ -10,6 +10,3 @@ sphinx-rtd-theme = "*"
 jsx-lexer = "*"
 sphinx-autobuild = "*"
 sphinx-sitemap = "*"
-
-[requires]
-python_version = "3.7"
diff --git a/docs/ref/core.rst b/docs/ref/core.rst
index 521d4ed6..7b70de84 100644
--- a/docs/ref/core.rst
+++ b/docs/ref/core.rst
@@ -15,10 +15,6 @@ The main responsibilities of this package are:
 Installation
 ============
 
-.. note::
-   If you're using :doc:`@lingui/react <react>`, you don't need to install
-   this package explicitly. Install :doc:`@lingui/react <react>` instead.
-
 .. code-block:: sh
 
    yarn add @lingui/core
@@ -34,7 +30,7 @@ Reference
 
    .. code-block:: js
 
-      type Catalogs = {[language: string]: Catalog}
+      type Catalogs = {[locale: string]: Catalog}
 
       // Example:
       const catalogs: Catalogs =  {
@@ -96,15 +92,15 @@ Reference
 
    The factory function accepts one optional parameter, ``options``:
 
-   .. js:attribute:: options.language
+   .. js:attribute:: options.locale
 
-      Initial active language.
+      Initial active locale.
 
       .. code-block:: jsx
 
          import { setupI18n } from "@lingui/core"
 
-         const i18n = setupI18n({ language: "en" })
+         const i18n = setupI18n({ locale: "en" })
 
          // This is a shortcut for:
          // const i18n = setupI18n()
@@ -114,14 +110,14 @@ Reference
 
       List of alternative locales (BCP 47 langauge tags) which are used for number and date
       formatting (some countries use more than one number/date format). If not set, active
-      language is used instead.
+      locale is used instead.
 
       .. code-block:: jsx
 
          import { setupI18n } from "@lingui/core"
 
          const i18n = setupI18n({
-            language: "ar",
+            locale: "ar",
             locales: ["en-UK", "ar-AS"]
          })
 
@@ -185,15 +181,15 @@ Reference
    Constructor for I18n class isn't exported from the package. Instead, always use
    :js:func:`setupI18n` factory function.
 
-   .. js:method:: load(catalogs: Catalogs)
+   .. js:method:: load(locale: string, catalog: Catalog)
 
-      Load message catalogs and merge them with already loaded ones.
+      Load catalog for given locale
 
       .. code-block:: js
 
          import { setupI18n } from "@lingui/core"
 
-         const messagesEn =  {
+         const messages =  {
             "Hello": "Hello",
             "Good bye": "Good bye",
 
@@ -243,34 +239,54 @@ Reference
                en: messagesEn,
             })
 
-   .. js:method:: activate(language [, locales])
+   .. js:method:: loadAll(catalogs: Catalogs)
 
-      Activate a language and locales. :js:meth:`_` from now on will return messages
-      in given language.
+      Load catalogs for multiple locales at once.
 
       .. code-block:: js
 
          import { setupI18n } from "@lingui/core"
 
-         const i18n = setupI18n({ language: "en" })
-         i18n._("Hello")           // Return "Hello" in English
+         const messagesEn =  {
+            "Hello": "Hello",
+            "Good bye": "Good bye",
 
-         i18n.activate("cs")
-         i18n._("Hello")           // Return "Hello" in Czech
+            // Just an example how catalog looks internally.
+            // Formatting of string messages works in development only.
+            // See note below.
+            "My name is {name}": "My name is {name}"
+         }
 
-   .. js:method:: use(language [, locales])
+         const messagesCs = {
+            "Hello": "Ahoj",
+            "Good bye": "Nashledanou",
+            "My name is {name}": "Jmenuji se {name}"
+         }
 
-      Activate a language and locales locally. This method returns a new instance of
-      :js:class:`I18n` and doesn't affect global language.
+         const i18n = setupI18n()
+         i18n.loadAll({
+            en: messagesEn,
+            cs: messagesCs
+         })
+
+         // This is the same as loading message catalogs separately per language:
+         // i18n.load("en", messagesEn)
+         // i18n.load("cs", messagesCs)
+
+   .. js:method:: activate(locale [, locales])
+
+      Activate a locale and locales. :js:meth:`_` from now on will return messages
+      in given locale.
 
       .. code-block:: js
 
          import { setupI18n } from "@lingui/core"
 
          const i18n = setupI18n({ language: "en" })
+         i18n._("Hello")           // Return "Hello" in English
 
-         i18n.use("cs")._("Hello") // Return "Hello" in Czech
-         i18n._("Hello")           // Return "Hello" in active language (English)
+         i18n.activate("cs")
+         i18n._("Hello")           // Return "Hello" in Czech
 
    .. js:method:: _(messageId [, values [, options]])
 
@@ -299,3 +315,29 @@ Reference
          // Message with custom messageId
          i18n._("msg.id", { name: "Tom" }, { defaults: "My name is {name}" })
 
+Events
+======
+
+activate
+--------
+
+Triggered when :js:meth:`I18n.activate` is called.
+
+Arguments:
+
+- ``locale`` - New locale which is about to be activated
+
+load
+----
+
+Triggered when :js:meth:`I18n.load` is called.
+
+Arguments:
+
+- ``locale`` - Locale of loaded catalog
+- ``catalog`` - Content of catalog.
+
+change
+------
+
+Triggered **after** locale is changed or new catalog is loaded. There're no arguments.
diff --git a/docs/releases/migration-3.rst b/docs/releases/migration-3.rst
index b88ce4bb..7570485c 100644
--- a/docs/releases/migration-3.rst
+++ b/docs/releases/migration-3.rst
@@ -62,6 +62,8 @@ is simplified and accepts ``i18n`` manager, which must be created manually:
 
 - ``i18n.t``, ``i18n.plural``, ``i18n.select`` and ``i18n.selectOrdinal`` methods were
   removed in favor of macros.
+- ``i18n.use`` was removed. Using two locales at the same time isn't common usecase
+  and can be solved in user land.
 - Signature of ``i18n._`` method has changed. The third parameter now accepts default
   message in ``message`` prop, instead of ``defaults``:
 
diff --git a/examples/react/lingui.config.js b/examples/react/lingui.config.js
index 49a2adb8..6cece009 100644
--- a/examples/react/lingui.config.js
+++ b/examples/react/lingui.config.js
@@ -1,6 +1,11 @@
 module.exports = {
+  locales: ["en", "cs"],
   sourceLocale: "en",
-  localeDir: "./src/locales",
-  srcPathDirs: ["./src/"],
-  format: "po"
+  format: "po",
+  catalogs: [
+    {
+      path: "./src/locales/{locale}",
+      include: ["./src"]
+    }
+  ]
 }
diff --git a/examples/react/src/App.js b/examples/react/src/App.js
index d75c921c..7a87e391 100644
--- a/examples/react/src/App.js
+++ b/examples/react/src/App.js
@@ -3,7 +3,7 @@ import { Tag, Button, message } from "antd"
 
 import { I18nProvider, useLingui } from "@lingui/react"
 import { t, Trans, Plural } from "@lingui/macro"
-import { i18n } from "./i18n"
+import { i18n } from "./i18n.config"
 
 import { Navigation } from "./helpers/Navigation"
 import { IntegerStepper } from "./helpers/IntegerStepper"
diff --git a/examples/react/src/helpers/Navigation.js b/examples/react/src/helpers/Navigation.js
index aa7d5836..7b819c03 100644
--- a/examples/react/src/helpers/Navigation.js
+++ b/examples/react/src/helpers/Navigation.js
@@ -2,7 +2,7 @@ import * as React from "react"
 import { Dropdown, Menu } from "antd"
 
 import { useLingui } from "@lingui/react"
-import { locales } from "../i18n"
+import { locales } from "../i18n.config"
 
 export function Navigation() {
   const { i18n } = useLingui()
diff --git a/examples/react/src/i18n.js b/examples/react/src/i18n.config.js
similarity index 67%
rename from examples/react/src/i18n.js
rename to examples/react/src/i18n.config.js
index 48ca06bb..ddf54670 100644
--- a/examples/react/src/i18n.js
+++ b/examples/react/src/i18n.config.js
@@ -9,9 +9,11 @@ export const locales = {
 
 function loadCatalog(locale) {
   /* webpackMode: "lazy", webpackChunkName: "i18n-[index]" */
-  return import(`@lingui/loader!./locales/${locale}/messages.po`)
+  return import(`@lingui/loader!./locales/${locale}.po`).then(catalog =>
+    i18n.load(locale, catalog)
+  )
 }
 
 export const i18n = setupI18n()
-i18n.willActivate(loadCatalog)
+i18n.on("activate", loadCatalog)
 i18n.activate(defaultLocale)
diff --git a/examples/react/src/locales/cs/messages.po b/examples/react/src/locales/cs.po
similarity index 71%
rename from examples/react/src/locales/cs/messages.po
rename to examples/react/src/locales/cs.po
index aedece6a..e5232c54 100644
--- a/examples/react/src/locales/cs/messages.po
+++ b/examples/react/src/locales/cs.po
@@ -13,90 +13,94 @@ msgstr ""
 "Language-Team: \n"
 "Plural-Forms: \n"
 
-#: src/App.js:74
+#: src/App.js:68
 msgid "<0>HTML</0> and <1>React components</1> works without extra configuration."
 msgstr "<0>HTML</0> a <1>React komponenty</1> nepotřebují žádnou konfiguraci."
 
 #: src/App.js:38
-msgid "All messages use ICU MessageFormat, but <0>Trans</0> macro generates this syntax from JSX."
-msgstr ""
+#~ msgid "All messages use ICU MessageFormat, but <0>Trans</0> macro generates this syntax from JSX."
+#~ msgstr ""
 
-#: src/App.js:70
+#: src/App.js:64
 msgid "Components"
 msgstr ""
 
 #: src/App.js:120
-msgid "Dates"
-msgstr ""
+#~ msgid "Dates"
+#~ msgstr ""
 
-#: src/App.js:58
+#: src/App.js:54
 msgid "Enter your name"
 msgstr ""
 
-#: src/App.js:101
+#: src/App.js:88
 msgid "Formatting"
 msgstr ""
 
-#: src/App.js:62
+#: src/App.js:58
 msgid "Hello {name}"
 msgstr ""
 
 #: src/App.js:134
-msgid "It costs {0,number,number0} which is {1,number,number1} of our income."
-msgstr ""
+#~ msgid "It costs {0,number,number0} which is {1,number,number1} of our income."
+#~ msgstr ""
 
-#: src/App.js:24
+#: src/App.js:29
 msgid "LinguiJS example"
 msgstr ""
 
-#: src/App.js:34
+#: src/App.js:39
 msgid "Message formatting"
 msgstr ""
 
-#: src/App.js:49
+#: src/App.js:47
 msgid "Messages can include variables:"
 msgstr ""
 
 #: src/App.js:130
-msgid "Numbers"
-msgstr ""
+#~ msgid "Numbers"
+#~ msgstr ""
 
-#: src/App.js:105
+#: src/App.js:92
 msgid "Plurals"
 msgstr ""
 
-#: src/App.js:81
+#: src/App.js:75
 msgid "Props and strings"
 msgstr ""
 
-#: src/App.js:85
+#: src/App.js:79
 msgid "React props and strings can be translated using <0>i18n</0> core:"
 msgstr ""
 
-#: src/App.js:20
+#: src/App.js:25
 msgid "Select language:"
 msgstr "Jazyk rozhraní:"
 
-#: src/App.js:94
+#: src/App.js:83
 msgid "Show motto of the day"
 msgstr ""
 
-#: src/App.js:28
+#: src/App.js:33
 msgid "This is an example integration of LinguiJS with React apps."
 msgstr ""
 
 #: src/App.js:124
-msgid "Today is {0,date}"
-msgstr ""
+#~ msgid "Today is {0,date}"
+#~ msgstr ""
 
-#: src/App.js:45
+#: src/App.js:43
 msgid "Variables"
 msgstr ""
 
-#: src/App.js:92
+#: src/App.js:52
+msgid "World"
+msgstr ""
+
+#: src/App.js:82
 msgid "You're looking good!"
 msgstr ""
 
-#: src/App.js:110
+#: src/App.js:97
 msgid "{count, plural, =0 {There're no books} one {There's one book} other {There're # books}}"
 msgstr "{count, plural, =0 {Není tam žádná kniha} one {Je tam jedna kniha} few {Jsou tam # knihy} other {Je tam # knih}}"
diff --git a/examples/react/src/locales/en/messages.po b/examples/react/src/locales/en.po
similarity index 69%
rename from examples/react/src/locales/en/messages.po
rename to examples/react/src/locales/en.po
index 32bd7a95..bd3ef280 100644
--- a/examples/react/src/locales/en/messages.po
+++ b/examples/react/src/locales/en.po
@@ -13,94 +13,98 @@ msgstr ""
 "Language-Team: \n"
 "Plural-Forms: \n"
 
-#: src/App.js:74
+#: src/App.js:68
 msgid "<0>HTML</0> and <1>React components</1> works without extra configuration."
 msgstr ""
 
 #: src/App.js:38
-msgid "All messages use ICU MessageFormat, but <0>Trans</0> macro generates this syntax from JSX."
-msgstr ""
+#~ msgid "All messages use ICU MessageFormat, but <0>Trans</0> macro generates this syntax from JSX."
+#~ msgstr ""
 
-#: src/App.js:70
+#: src/App.js:64
 msgid "Components"
 msgstr ""
 
 #: src/App.js:120
-msgid "Dates"
-msgstr ""
+#~ msgid "Dates"
+#~ msgstr ""
 
-#: src/App.js:58
+#: src/App.js:54
 msgid "Enter your name"
 msgstr ""
 
-#: src/App.js:101
+#: src/App.js:88
 msgid "Formatting"
 msgstr ""
 
-#: src/App.js:62
+#: src/App.js:58
 msgid "Hello {name}"
 msgstr ""
 
 #: src/App.js:134
-msgid "It costs {0,number,number0} which is {1,number,number1} of our income."
-msgstr ""
+#~ msgid "It costs {0,number,number0} which is {1,number,number1} of our income."
+#~ msgstr ""
 
-#: src/App.js:24
+#: src/App.js:29
 msgid "LinguiJS example"
 msgstr ""
 
-#: src/App.js:34
+#: src/App.js:39
 msgid "Message formatting"
 msgstr ""
 
-#: src/App.js:49
+#: src/App.js:47
 msgid "Messages can include variables:"
 msgstr ""
 
 #: src/App.js:130
-msgid "Numbers"
-msgstr ""
+#~ msgid "Numbers"
+#~ msgstr ""
 
 #: src/App.js:121
 #~ msgid "Pluralization"
 #~ msgstr "Pluralization"
 
-#: src/App.js:105
+#: src/App.js:92
 msgid "Plurals"
 msgstr ""
 
-#: src/App.js:81
+#: src/App.js:75
 msgid "Props and strings"
 msgstr ""
 
-#: src/App.js:85
+#: src/App.js:79
 msgid "React props and strings can be translated using <0>i18n</0> core:"
 msgstr ""
 
-#: src/App.js:20
+#: src/App.js:25
 msgid "Select language:"
 msgstr ""
 
-#: src/App.js:94
+#: src/App.js:83
 msgid "Show motto of the day"
 msgstr ""
 
-#: src/App.js:28
+#: src/App.js:33
 msgid "This is an example integration of LinguiJS with React apps."
 msgstr ""
 
 #: src/App.js:124
-msgid "Today is {0,date}"
-msgstr ""
+#~ msgid "Today is {0,date}"
+#~ msgstr ""
 
-#: src/App.js:45
+#: src/App.js:43
 msgid "Variables"
 msgstr ""
 
-#: src/App.js:92
+#: src/App.js:52
+msgid "World"
+msgstr "World"
+
+#: src/App.js:82
 msgid "You're looking good!"
 msgstr ""
 
-#: src/App.js:110
+#: src/App.js:97
 msgid "{count, plural, =0 {There're no books} one {There's one book} other {There're # books}}"
 msgstr ""
diff --git a/now-deploy-docs.sh b/now-deploy-docs.sh
new file mode 100755
index 00000000..dc9a08cf
--- /dev/null
+++ b/now-deploy-docs.sh
@@ -0,0 +1,4 @@
+pip install pipenv
+pipenv install
+
+cd docs; pipenv run make html
diff --git a/now.json b/now.json
new file mode 100644
index 00000000..ba04c9ea
--- /dev/null
+++ b/now.json
@@ -0,0 +1,11 @@
+{
+  "version": 2,
+  "name": "lingui-docs",
+  "builds": [
+    {
+      "src": "./now-deploy-docs.sh",
+      "use": "@now/static-build",
+      "config": { "distDir": "./docs/_build/html" }
+    }
+  ]
+}
diff --git a/package.json b/package.json
index 6fd8d5cc..52fc1337 100644
--- a/package.json
+++ b/package.json
@@ -69,6 +69,7 @@
     "ncp": "^2.0.0",
     "nixt": "^0.5.0",
     "node-emoji": "^1.10.0",
+    "now": "^15.3.0",
     "ora": "^3.4.0",
     "pkg-up": "^3.1.0",
     "prettier": "^1.17.1",
diff --git a/packages/core/src/eventEmitter.ts b/packages/core/src/eventEmitter.ts
new file mode 100644
index 00000000..e1073ca6
--- /dev/null
+++ b/packages/core/src/eventEmitter.ts
@@ -0,0 +1,39 @@
+export class EventEmitter<
+  Events extends { [name: string]: (...args: any[]) => any }
+> {
+  private readonly _events: {
+    [name in keyof Events]?: Array<Events[name]>
+  } = {}
+
+  on(event: keyof Events, listener: Events[typeof event]): () => void {
+    if (!this._hasEvent(event)) this._events[event] = []
+
+    this._events[event].push(listener)
+    return () => this.removeListener(event, listener)
+  }
+
+  removeListener(event: keyof Events, listener: Events[typeof event]): void {
+    if (!this._hasEvent(event)) return
+
+    const index = this._events[event].indexOf(listener)
+    if (~index) this._events[event].splice(index, 1)
+  }
+
+  emit(event: keyof Events, ...args: Parameters<Events[typeof event]>): Promise<any> {
+    if (!this._hasEvent(event)) return Promise.resolve()
+
+    return Promise.all(
+      this._events[event]
+        .map(listener => listener.apply(this, args))
+        .map(result =>
+          result == null || result.then == null
+            ? Promise.resolve(result)
+            : result
+        )
+    )
+  }
+
+  private _hasEvent(event: keyof Events) {
+    return Array.isArray(this._events[event])
+  }
+}
diff --git a/packages/core/src/i18n.ts b/packages/core/src/i18n.ts
index bc9162fe..bb9d3233 100644
--- a/packages/core/src/i18n.ts
+++ b/packages/core/src/i18n.ts
@@ -3,6 +3,7 @@ import { isString, isFunction, isEmpty } from "./essentials"
 import { date, number } from "./formats"
 import * as icu from "./dev"
 import { MessageDescriptor } from "./messages"
+import { EventEmitter } from "./eventEmitter"
 
 export type MessageOptions = {
   message?: string
@@ -32,21 +33,27 @@ type setupI18nProps = {
   missing?: string | ((message, id) => string)
 }
 
-export class I18n {
+type Events = {
+  activate: (locale: string) => Promise<any>
+  load: (locale: string, catalog: Catalog | null) => Promise<any>
+  change: () => Promise<any>
+}
+
+export class I18n extends EventEmitter<Events> {
   messageFormat: typeof icu
   _catalogs: Catalogs
-  _didActivate: Array<Function>
-  _willActivate: Array<Function>
   _locale: Locale
   _locales: Locales
   _missing: string | ((message, id) => string)
 
-  constructor() {
-    // Messages and localeData are merged on load,
-    // so we must initialize it manually
+  constructor(params: setupI18nProps) {
+    super()
+
     this._catalogs = {}
-    this._didActivate = []
-    this._willActivate = []
+    if (params.catalogs != null) this._loadAll(params.catalogs)
+    if (params.locale != null || params.locales != null) {
+      this._activate(params.locale, params.locales)
+    }
   }
 
   get locale() {
@@ -81,38 +88,37 @@ export class I18n {
     return this.catalog.localeData
   }
 
+  private _loadAll(catalogs: Catalogs) {
+    Object.keys(catalogs).map(locale => this._load(locale, catalogs[locale]))
+  }
+
   loadAll(catalogs: Catalogs) {
-    Object.keys(catalogs).forEach(locale => this.load(locale, catalogs[locale]))
+    return Object.keys(catalogs).map(locale =>
+      this.load(locale, catalogs[locale])
+    )
   }
 
-  load(locale: Locale, catalog?: Catalog): Promise<void> {
-    if (catalog == null) {
-      return Promise.all(
-        this._willActivate.map(load =>
-          load(locale).then(catalog => this.load(locale, catalog))
-        )
-      ).then(() => {})
-    }
+  private _load(locale: Locale, catalog: Catalog) {
+    if (catalog == null) return
 
     if (this._catalogs[locale] == null) {
-      this._catalogs[locale] = {
-        messages: {},
-        localeData: {}
-      }
+      this._catalogs[locale] = catalog
+    } else {
+      const prev = this._catalogs[locale]
+      Object.assign(prev.messages, catalog.messages)
+      Object.assign(prev.localeData, catalog.localeData)
     }
+  }
 
-    const prev = this._catalogs[locale]
-    Object.assign(prev.messages, catalog.messages)
-    Object.assign(prev.localeData, catalog.localeData)
-    return Promise.resolve()
+  load(locale: Locale, catalog?: Catalog) {
+    return this.emit("load", locale, catalog).then(() => {
+      this._load(locale, catalog)
+      return this.emit("change")
+    })
   }
 
-  activate(locale: Locale, locales?: Locales) {
+  private _activate(locale: Locale, locales?: Locales) {
     if (!this._catalogs[locale]) {
-      if (this._willActivate.length) {
-        return this.load(locale).then(() => this.activate(locale, locales))
-      }
-
       if (process.env.NODE_ENV !== "production") {
         console.warn(`Message catalog for locale "${locale}" not loaded.`)
       }
@@ -120,25 +126,14 @@ export class I18n {
 
     this._locale = locale
     this._locales = locales
-    this._didActivate.forEach(s => s())
-    return Promise.resolve()
   }
 
-  use(locale: Locale, locales?: Locales) {
-    const i18n = setupI18n()
-    i18n._catalogs = this._catalogs
-    i18n.activate(locale, locales)
-    return i18n
-  }
-
-  willActivate(callback: (locale: Locale) => Promise<Catalog>): Function {
-    this._willActivate.push(callback)
-    return () => this._willActivate.filter(cb => cb !== callback)
-  }
+  activate(locale: Locale, locales?: Locales) {
+    return this.emit("activate", locale).then(() => {
+      this._activate(locale, locales)
 
-  didActivate(callback: () => void): Function {
-    this._didActivate.push(callback)
-    return () => this._didActivate.filter(cb => cb !== callback)
+      return this.emit("change")
+    })
   }
 
   // default translate method
@@ -183,11 +178,9 @@ export class I18n {
 }
 
 function setupI18n(params: setupI18nProps = {}): I18n {
-  const i18n = new I18n()
+  const i18n = new I18n(params)
   i18n.messageFormat = icu
 
-  if (params.catalogs) i18n.loadAll(params.catalogs)
-  if (params.locale) i18n.activate(params.locale, params.locales)
   if (params.missing) i18n._missing = params.missing
 
   return i18n
diff --git a/packages/react/src/I18nProvider.tsx b/packages/react/src/I18nProvider.tsx
index 3184ecb7..c8e27be6 100644
--- a/packages/react/src/I18nProvider.tsx
+++ b/packages/react/src/I18nProvider.tsx
@@ -36,7 +36,7 @@ export const I18nProvider = (props: I18nProviderProps) => {
    * we need to trigger re-rendering of LinguiContext.Consumers.
    */
   React.useEffect(() => {
-    const unsubscribe = props.i18n.didActivate(() => setContext(makeContext()))
+    const unsubscribe = props.i18n.on("change", () => setContext(makeContext()))
     return () => unsubscribe()
   }, [])
 
diff --git a/scripts/jest/config.unit.js b/scripts/jest/config.unit.js
index e356e639..89c66083 100644
--- a/scripts/jest/config.unit.js
+++ b/scripts/jest/config.unit.js
@@ -15,7 +15,7 @@ module.exports = {
     ".*.json$",
     ".*.js.snap$"
   ],
-  coverageReporters: ["html", "lcov"],
+  coverageReporters: ["html", "lcov", "text"],
 
   reporters: ["default", "jest-junit"],
   setupFilesAfterEnv: [
diff --git a/yarn.lock b/yarn.lock
index 6417ec6b..6af1987e 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -5338,6 +5338,11 @@ normalize-path@^2.1.1:
   dependencies:
     remove-trailing-separator "^1.0.1"
 
+now@^15.3.0:
+  version "15.3.0"
+  resolved "https://registry.yarnpkg.com/now/-/now-15.3.0.tgz#cae6d6e37d9babea524c4125e842d6dbd54cd217"
+  integrity sha512-QuPHIlvcDKHaj39lR2VugeguO7JOtMZ5Ff2XcEPzy2VxsV/ocIhlDiNxV5P4nhl5rzzQt8bn9TFh33n4vO5yIA==
+
 npm-bundled@^1.0.1:
   version "1.0.5"
   resolved "https://registry.yarnpkg.com/npm-bundled/-/npm-bundled-1.0.5.tgz#3c1732b7ba936b3a10325aef616467c0ccbcc979"


Test patch:
diff --git a/packages/core/src/eventEmitter.test.ts b/packages/core/src/eventEmitter.test.ts
new file mode 100644
index 00000000..d93d9e3e
--- /dev/null
+++ b/packages/core/src/eventEmitter.test.ts
@@ -0,0 +1,55 @@
+import { EventEmitter } from "./eventEmitter"
+
+describe("@lingui/core/eventEmitter", () => {
+  it("should call registered event listeners on emit", async () => {
+    const withoutResponse = jest.fn()
+    const withSyncResponse = jest.fn(() => "sync")
+    const withAsyncResponse = jest.fn(() => Promise.resolve("async"))
+
+    const emitter = new EventEmitter()
+    emitter.on("test", withoutResponse)
+    emitter.on("test", withSyncResponse)
+    emitter.on("test", withAsyncResponse)
+
+    const result = await emitter.emit("test", 42)
+
+    expect(withoutResponse).toBeCalledWith(42)
+    expect(withSyncResponse).toBeCalledWith(42)
+    expect(withAsyncResponse).toBeCalledWith(42)
+    expect(result).toEqual([undefined, "sync", "async"])
+  })
+
+  it("should allow unsubscribing from events", () => {
+    const listener = jest.fn()
+    const emitter = new EventEmitter()
+
+    const unsubscribe = emitter.on("test", listener)
+    emitter.emit("test", 42)
+    expect(listener).toBeCalledWith(42)
+
+    listener.mockReset()
+    unsubscribe()
+    emitter.emit("test", 42)
+    expect(listener).not.toBeCalled()
+  })
+
+  it("emit should always return a promise", () => {
+    const emitter = new EventEmitter()
+    const response = emitter.emit("test", 42)
+    expect(response.then).toBeDefined()
+  })
+
+  it("should do nothing when even doesn't exist", () => {
+    const unknown = jest.fn()
+
+    const emitter = new EventEmitter()
+    // this should not throw
+    emitter.emit("test", 42)
+    // this should not throw
+    emitter.removeListener("test", unknown)
+
+    emitter.on("test", jest.fn())
+    // this should not throw
+    emitter.removeListener("test", unknown)
+  })
+})
diff --git a/packages/core/src/i18n.test.ts b/packages/core/src/i18n.test.ts
index 20e47825..eeb25b16 100644
--- a/packages/core/src/i18n.test.ts
+++ b/packages/core/src/i18n.test.ts
@@ -2,118 +2,115 @@ import { setupI18n } from "@lingui/core"
 import { mockConsole, mockEnv } from "@lingui/jest-mocks"
 
 describe("I18n", function() {
-  it(".load should load catalog and merge with existing", function() {
-    const messages = {
-      Hello: "Hello"
-    }
+  describe("I18n.load", () => {
+    it("should emit event", async () => {
+      expect.assertions(3)
 
-    const localeData = {
-      plurals: jest.fn(),
-      code: "en_US"
-    }
+      const i18n = setupI18n()
 
-    const i18n = setupI18n()
-    i18n.load("en", { messages, localeData })
-    i18n.activate("en")
-    expect(i18n.messages).toEqual(messages)
-    expect(i18n.localeData).toEqual(localeData)
+      const cbLoad = jest.fn()
+      const cbChange = jest.fn()
+      i18n.on("load", cbLoad)
+      i18n.on("change", cbChange)
+      const loading = i18n.load("en", { msg: "Message" })
 
-    // fr catalog shouldn't affect the english one
-    i18n.load("fr", { messages: { Hello: "Salut" } })
-    expect(i18n.messages).toEqual(messages)
-  })
+      expect(cbLoad).toBeCalledWith("en", { msg: "Message" })
+      expect(cbChange).not.toBeCalled()
 
-  it(".activate should switch active locale", function() {
-    const messages = {
-      Hello: "Salut"
-    }
-
-    const i18n = setupI18n({
-      locale: "en",
-      catalogs: {
-        fr: { messages },
-        en: { messages: {} }
-      }
+      await loading
+      expect(cbChange).toBeCalled()
     })
 
-    expect(i18n.locale).toEqual("en")
-    expect(i18n.messages).toEqual({})
-
-    i18n.activate("fr")
-    expect(i18n.locale).toEqual("fr")
-    expect(i18n.messages).toEqual(messages)
-  })
+    it("should load catalog and merge with existing", async () => {
+      const messages = {
+        Hello: "Hello"
+      }
 
-  it(".activate should throw an error about incorrect locale", function() {
-    const i18n = setupI18n()
+      const localeData = {
+        plurals: jest.fn(),
+        code: "en_US"
+      }
 
-    mockConsole(console => {
-      i18n.activate("xyz")
-      expect(console.warn).toBeCalledWith(
-        'Message catalog for locale "xyz" not loaded.'
-      )
-    })
+      const i18n = setupI18n()
+      await i18n.load("en", { messages, localeData })
+      await i18n.activate("en")
+      expect(i18n.messages).toEqual(messages)
+      expect(i18n.localeData).toEqual(localeData)
 
-    mockEnv("production", () => {
-      jest.resetModules()
-      mockConsole(console => {
-        const { setupI18n } = require("@lingui/core")
-        const i18n = setupI18n()
-        i18n.activate("xyz")
-        expect(console.warn).not.toBeCalled()
-      })
+      // fr catalog shouldn't affect the english one
+      await i18n.load("fr", { messages: { Hello: "Salut" } })
+      expect(i18n.messages).toEqual(messages)
     })
   })
 
-  it(".use should return new i18n object with switched locale", function() {
-    const catalogs = {
-      en: {
-        messages: {
-          Hello: "Hello"
-        }
-      },
-      fr: {
-        messages: {
-          Hello: "Salut"
+  describe("I18n.activate", () => {
+    it("should emit event", async () => {
+      expect.assertions(3)
+
+      const i18n = setupI18n({
+        locale: "en",
+        catalogs: {
+          en: { messages: {} }
         }
-      }
-    }
+      })
 
-    const i18n = setupI18n({
-      locale: "en",
-      catalogs
+      const cbActivate = jest.fn()
+      const cbChange = jest.fn()
+      i18n.on("activate", cbActivate)
+      i18n.on("change", cbChange)
+
+      const activating = i18n.activate("en")
+      expect(cbActivate).toBeCalledWith("en")
+      expect(cbChange).not.toBeCalled()
+
+      await activating
+      expect(cbChange).toBeCalled()
     })
 
-    expect(i18n._("Hello")).toEqual("Hello")
+    it("should switch active locale", async () => {
+      expect.assertions(4)
 
-    // change locale locally
-    expect(i18n.use("fr")._("Hello")).toEqual("Salut")
+      const messages = {
+        Hello: "Salut"
+      }
 
-    // global locale hasn't changed
-    expect(i18n._("Hello")).toEqual("Hello")
-  })
+      const i18n = setupI18n({
+        locale: "en",
+        catalogs: {
+          fr: { messages },
+          en: { messages: {} }
+        }
+      })
 
-  it(".use should return new i18n object with switched locales", function() {
-    const i18n = setupI18n({
-      locale: "en",
-      locales: "en-UK"
-    })
-    const plural = "{value, plural, zero {لا كتاب} two {# الكتب}}"
+      expect(i18n.locale).toEqual("en")
+      expect(i18n.messages).toEqual({})
 
-    // change locales locally
-    const ar = i18n.use("ar")
-    expect(ar._(plural, { value: 2 })).toEqual("٢ الكتب")
+      await i18n.activate("fr")
+      expect(i18n.locale).toEqual("fr")
+      expect(i18n.messages).toEqual(messages)
+    })
 
-    const uae = i18n.use("ar", "en-UK")
-    expect(uae._(plural, { value: 2 })).toEqual("2 الكتب")
+    it("should throw an error about incorrect locale", async () => {
+      expect.assertions(2)
+      const i18n = setupI18n()
 
-    const uae2 = i18n.use("ar", ["unknown-locale", "en-UK"])
-    expect(uae2._(plural, { value: 2 })).toEqual("2 الكتب")
+      await mockConsole(async console => {
+        await i18n.activate("xyz")
+        expect(console.warn).toBeCalledWith(
+          'Message catalog for locale "xyz" not loaded.'
+        )
+      })
 
-    // global locales hasn't changed
-    expect(
-      i18n._("{value, plural, one {# book} other {# books}}", { value: 2 })
-    ).toEqual("2 books")
+      await mockEnv("production", async () => {
+        jest.resetModules()
+        await mockConsole(async console => {
+          const { setupI18n } = require("@lingui/core")
+          const i18n = setupI18n()
+          await i18n.activate("xyz")
+          expect(console.warn).not.toBeCalled()
+        })
+      })
+    })
   })
 
   it("._ should format message from catalog", function() {
diff --git a/packages/react/src/I18nProvider.test.tsx b/packages/react/src/I18nProvider.test.tsx
index 16da7afe..51ceaf8d 100644
--- a/packages/react/src/I18nProvider.test.tsx
+++ b/packages/react/src/I18nProvider.test.tsx
@@ -1,5 +1,5 @@
 import * as React from "react"
-import { render, act } from "react-testing-library"
+import { render } from "react-testing-library"
 
 import { I18nProvider, useLingui } from "@lingui/react"
 import { setupI18n } from "@lingui/core"
@@ -7,21 +7,21 @@ import { setupI18n } from "@lingui/core"
 describe("I18nProvider", function() {
   it("should subscribe for locale changes", function() {
     const i18n = setupI18n()
-    i18n.didActivate = jest.fn(() => jest.fn())
+    i18n.on = jest.fn(() => jest.fn())
 
-    expect(i18n.didActivate).not.toBeCalled()
+    expect(i18n.on).not.toBeCalled()
     render(
       <I18nProvider i18n={i18n}>
         <div />
       </I18nProvider>
     )
-    expect(i18n.didActivate).toBeCalled()
+    expect(i18n.on).toBeCalledWith("change", expect.anything())
   })
 
   it("should unsubscribe for locale changes on unmount", function() {
     const unsubscribe = jest.fn()
     const i18n = setupI18n()
-    i18n.didActivate = jest.fn(() => unsubscribe)
+    i18n.on = jest.fn(() => unsubscribe)
 
     const { unmount } = render(
       <I18nProvider i18n={i18n}>
@@ -33,7 +33,9 @@ describe("I18nProvider", function() {
     expect(unsubscribe).toBeCalled()
   })
 
-  it("should re-render on locale changes", function() {
+  it("should re-render on locale changes", async () => {
+    expect.assertions(2)
+
     const i18n = setupI18n({
       locale: "en",
       catalogs: {}
@@ -51,9 +53,7 @@ describe("I18nProvider", function() {
     )
     expect(container.textContent).toEqual("en")
 
-    act(() => {
-      i18n.activate("cs")
-    })
+    await i18n.activate("cs")
     expect(container.textContent).toEqual("cs")
   })