Repository: lingui/js-lingui (ts)

Issue:
Performance issues with formatters
**Is your feature request related to a problem? Please describe.**

Lingui takes a lot of time to create number and date formatters, showing in the profiles consuming 25% to 85% of render time. 

This is due to repeatedly creating Number and Date formatters: https://github.com/lingui/js-lingui/blob/master/packages/core/src/formats.js#L31  and https://github.com/lingui/js-lingui/blob/master/packages/core/src/formats.js#L20 , which is a very expensive operation (at least in some browsers - I we tested this in Chrome)

**Describe the solution you'd like**
We'd like to have a API to provide custom number/date formatter so we can use memoized formatters. This is also in the wishlist for Lingui 3, as I can see. 

**Describe alternatives you've considered**

As a temporary solution until we get a proper one we did this: https://github.com/SectorLabs/js-lingui/pull/6 in our fork of lingui



Pull request:
fix: improved performance of formatters
Will close #472

I've added to format functions a third param to disable/enable the memoize function for testing purposes in that way we can be 100% sure that the cache is working properly.

Some results (1000x renders of `date`)
```
    ┌──────────────────────┬────────────────────┐
    │       (index)        │       Values       │
    ├──────────────────────┼────────────────────┤
    │  memoizedDateResult  │  6.58090500000003  │
    │ withoutMemoizeResult │ 263.14999799999987 │
    └──────────────────────┴────────────────────┘
```

Some results (1000x renders of `number`)
```
    ┌──────────────────────┬────────────────────┐
    │       (index)        │       Values       │
    ├──────────────────────┼────────────────────┤
    │ memoizedNumberResult │ 2.9453200000002653 │
    │ withoutMemoizeResult │ 45.79147499999999  │
    └──────────────────────┴────────────────────┘
```



Patch:
diff --git a/packages/core/src/context.ts b/packages/core/src/context.ts
index 2f31ebc1..59da04bb 100644
--- a/packages/core/src/context.ts
+++ b/packages/core/src/context.ts
@@ -16,8 +16,7 @@ const defaultFormats = (
     return (ctx) => {
       const msg = isFunction(message) ? message(ctx) : message
       const norm = Array.isArray(msg) ? msg : [msg]
-      const formatter = new Intl.NumberFormat(locales)
-      const valueStr = formatter.format(value)
+      const valueStr = number(locales)(value)
       return norm.map((m) => (isString(m) ? m.replace("#", valueStr) : m))
     }
   }
diff --git a/packages/core/src/formats.ts b/packages/core/src/formats.ts
index 99d39619..1f869072 100644
--- a/packages/core/src/formats.ts
+++ b/packages/core/src/formats.ts
@@ -1,21 +1,60 @@
 import { isString } from "./essentials"
 import { Locales } from "./i18n"
 
+/** Memoized cache */
+const numberFormats = new Map<string, Intl.NumberFormat>()
+const dateFormats = new Map<string, Intl.DateTimeFormat>()
+
 export function date(
   locales: Locales,
-  format: Intl.DateTimeFormatOptions = {}
+  format: Intl.DateTimeFormatOptions = {},
+  memoize: boolean = true,
 ): (value: string | Date) => string {
-  const formatter = new Intl.DateTimeFormat(locales, format)
   return (value) => {
     if (isString(value)) value = new Date(value)
+    if (memoize) {
+      const key = cacheKey<Intl.DateTimeFormatOptions>(locales, format)
+      if (dateFormats.has(key)) {
+        return dateFormats.get(key).format(value)
+      }
+
+      const formatter = new Intl.DateTimeFormat(locales, format)
+      dateFormats.set(key, formatter)
+      return formatter.format(value)
+    }
+
+    const formatter = new Intl.DateTimeFormat(locales, format)
     return formatter.format(value)
   }
 }
 
 export function number(
   locales: Locales,
-  format: Intl.NumberFormatOptions = {}
+  format: Intl.NumberFormatOptions = {},
+  memoize: boolean = true,
 ): (value: number) => string {
-  const formatter = new Intl.NumberFormat(locales, format)
-  return (value) => formatter.format(value)
+  return (value) => {
+    if (memoize) {
+      const key = cacheKey<Intl.NumberFormatOptions>(locales, format)
+      if (numberFormats.has(key)) {
+        return numberFormats.get(key).format(value)
+      }
+
+      const formatter = new Intl.NumberFormat(locales, format)
+      numberFormats.set(key, formatter)
+      return formatter.format(value)
+    }
+
+    const formatter = new Intl.NumberFormat(locales, format)
+    return formatter.format(value)
+  }
 }
+
+/** Memoize helpers */
+function cacheKey<T>(
+  locales?: string | string[],
+  options: T = {} as T,
+) {
+  const localeKey = Array.isArray(locales) ? locales.sort().join('-') : locales
+  return `${localeKey}-${JSON.stringify(options)}`
+}
\ No newline at end of file


Test patch:
diff --git a/packages/core/src/formats.test.ts b/packages/core/src/formats.test.ts
new file mode 100644
index 00000000..51cd7cb5
--- /dev/null
+++ b/packages/core/src/formats.test.ts
@@ -0,0 +1,66 @@
+import { date, number } from "./formats"
+
+describe("@lingui/core/formats", () => {
+  it("number formatter is memoized", async () => {
+    const firstRunt0 = performance.now()
+    number("es", {})(10000)
+    const firstRunt1 = performance.now()
+    const firstRunResult = firstRunt1 - firstRunt0
+
+    const seconddRunt0 = performance.now()
+    number("es", {})(10000)
+    const seconddRunt1 = performance.now()
+    const secondRunResult = seconddRunt1 - seconddRunt0
+
+    expect(secondRunResult).toBeLessThan(firstRunResult)
+  })
+  it("date formatter is memoized", async () => {
+    const firstRunt0 = performance.now()
+    date("es", {})(new Date())
+    const firstRunt1 = performance.now()
+    const firstRunResult = firstRunt1 - firstRunt0
+
+    const seconddRunt0 = performance.now()
+    date("es", {})(new Date())
+    const seconddRunt1 = performance.now()
+    const secondRunResult = seconddRunt1 - seconddRunt0
+
+    expect(secondRunResult).toBeLessThan(firstRunResult)
+  })
+
+  it("date memoized function is faster than the not memoized function", () => {
+    const loopt0 = performance.now()
+    for (let i = 0; i < 1000; i++) {
+      date("es", {})(new Date())
+    }
+    const loopt1 = performance.now()
+    const memoizedDateResult = loopt1 - loopt0
+
+    const loop0 = performance.now()
+    for (let i = 0; i < 1000; i++) {
+      date("es", {}, false)(new Date())
+    }
+    const loop1 = performance.now()
+    const withoutMemoizeResult = loop1 - loop0
+
+    expect(memoizedDateResult).toBeLessThan(withoutMemoizeResult)
+  })
+
+  it("number memoized function is faster than the not memoized function", () => {
+    const loopt0 = performance.now()
+    for (let i = 0; i < 1000; i++) {
+      number("es", {})(999666)
+    }
+    const loopt1 = performance.now()
+    const memoizedNumberResult = loopt1 - loopt0
+
+    const loop0 = performance.now()
+    for (let i = 0; i < 1000; i++) {
+      number("es", {}, false)(999666)
+    }
+    const loop1 = performance.now()
+    const withoutMemoizeResult = loop1 - loop0
+
+    expect(memoizedNumberResult).toBeLessThan(withoutMemoizeResult)
+  })
+})