Skip to content

Commit

Permalink
feat: Improve runtime performance of useTranslations by avoiding th…
Browse files Browse the repository at this point in the history
…e creation of message format instances if possible and introducing a cross-component message format cache (#475)

Fixes #294
  • Loading branch information
amannn authored Aug 23, 2023
1 parent 54b585c commit 4d177f8
Show file tree
Hide file tree
Showing 8 changed files with 240 additions and 47 deletions.
78 changes: 57 additions & 21 deletions docs/pages/docs/usage/messages.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -113,39 +113,75 @@ There's one exception however: [Using `next-intl` with the Next.js Metadata API

</details>

## Rendering messages
## Rendering ICU messages

`next-intl` uses ICU message syntax that allows you to express language nuances and separates state handling within messages from your app code.

You can interpolate values from your components into your messages with a special placeholder syntax.
<Callout>

To work with ICU messages, it can be helpful to use an editor that supports this syntax. E.g. the <PartnerContentLink name="messages-rendering" href="https://support.crowdin.com/icu-message-syntax/">Crowdin Editor</PartnerContentLink> can be used by translators to work on translations without having to change app code.

</Callout>

### Static messages

```js filename="en.json"
{
"static": "Hello world!",
"interpolation": "Hello {name}!",
"plural": "You have {numMessages, plural, =0 {no messages} =1 {one message} other {# messages}}.",
"select": "{gender, select, female {She} male {He} other {They}} is online.",
"selectordinal": "It's your {year, selectordinal, one {#st} two {#nd} few {#rd} other {#th}} birthday!",
"escaped": "Escape curly braces with single quotes (e.g. '{name'})"
}
"message": "Hello world!"
```

```js
const t = useTranslations();
t('message'); // "Hello world!"
```

### Interpolation of dynamic values

t('static');
t('interpolation', {name: 'Jane'});
t('plural', {numMessages: 3});
t('select', {gender: 'female'});
t('selectordinal', {year: 11});
t('escaped');
```js filename="en.json"
"message": "Hello {name}!"
```

<Callout>
```js
t('message', {name: 'Jane'}); // "Hello Jane!"
```

To work with ICU messages, it can be helpful to use an editor that supports this syntax. E.g. the <PartnerContentLink name="messages-rendering" href="https://support.crowdin.com/icu-message-syntax/">Crowdin Editor</PartnerContentLink> can be used by translators to work on translations without having to change app code.
### Pluralization

</Callout>
```js filename="en.json"
"message": "You have {count, plural, =0 {no followers yet} =1 {one follower} other {# followers}}."
```

```js
t('message', {count: 3580}); // "You have 3,580 followers."
```

### Ordinal pluralization

```js filename="en.json"
"message": "It's your {year, selectordinal, one {#st} two {#nd} few {#rd} other {#th}} birthday!"
```

```js
t('message', {year: 21}); // "It's your 21st birthday!"
```

### Selecting enum-based values

```js filename="en.json"
"message": "{gender, select, female {She} male {He} other {They}} is online."
```

```js
t('message', {gender: 'female'}); // "She is online."
```

### Escaping

```js filename="en.json"
"message": "Escape curly braces with single quotes (e.g. '{name'})"
```

```js
t('message'); // "Escape curly braces with single quotes (e.g. {name})"
```

## Rich text

Expand All @@ -164,7 +200,7 @@ t.rich('richText', {
});
```

If you want to use the same tag multiple times, you can configure it via the [default translation values](/docs/configuration#default-translation-values).
If you want to use the same tag across your app, you can configure it via the [default translation values](/docs/configuration#default-translation-values).

Note that the ICU parser doesn't support self-closing tags at this point, therefore you have to use syntax like `<br></br>` if your rich text function doesn't intend to receive any `chunks` (e.g. `br: () => <br />`).

Expand Down
10 changes: 10 additions & 0 deletions packages/use-intl/src/core/MessageFormatCache.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
// eslint-disable-next-line import/no-named-as-default -- False positive
import type IntlMessageFormat from 'intl-messageformat';

type MessageFormatCache = Map<
/** Format: `${locale}.${namespace}.${key}.${message}` */
string,
IntlMessageFormat
>;

export default MessageFormatCache;
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
// eslint-disable-next-line import/no-named-as-default -- False positive
import IntlMessageFormat, {Formats as IntlFormats} from 'intl-messageformat';
import DateTimeFormatOptions from './DateTimeFormatOptions';
import Formats from './Formats';
Expand Down
41 changes: 29 additions & 12 deletions packages/use-intl/src/core/createBaseTranslator.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import AbstractIntlMessages from './AbstractIntlMessages';
import Formats from './Formats';
import {InitializedIntlConfig} from './IntlConfig';
import IntlError, {IntlErrorCode} from './IntlError';
import MessageFormatCache from './MessageFormatCache';
import TranslationValues, {RichTranslationValues} from './TranslationValues';
import convertFormatsToIntlMessageFormat from './convertFormatsToIntlMessageFormat';
import {defaultGetMessageFallback, defaultOnError} from './defaults';
Expand Down Expand Up @@ -122,21 +123,38 @@ export function getMessagesOrError<Messages extends AbstractIntlMessages>({
}

export type CreateBaseTranslatorProps<Messages> = InitializedIntlConfig & {
cachedFormatsByLocale?: Record<string, Record<string, IntlMessageFormat>>;
messageFormatCache?: MessageFormatCache;
defaultTranslationValues?: RichTranslationValues;
namespace?: string;
messagesOrError: Messages | IntlError;
};

function getPlainMessage(candidate: string, values?: unknown) {
if (values) return undefined;

const unescapedMessage = candidate.replace(/'([{}])/gi, '$1');

// Placeholders can be in the message if there are default values,
// or if the user has forgotten to provide values. In the latter
// case we need to compile the message to receive an error.
const hasPlaceholders = /<|{/.test(unescapedMessage);

if (!hasPlaceholders) {
return unescapedMessage;
}

return undefined;
}

export default function createBaseTranslator<
Messages extends AbstractIntlMessages,
NestedKey extends NestedKeyOf<Messages>
>({
cachedFormatsByLocale,
defaultTranslationValues,
formats: globalFormats,
getMessageFallback = defaultGetMessageFallback,
locale,
messageFormatCache,
messagesOrError,
namespace,
onError,
Expand Down Expand Up @@ -185,11 +203,11 @@ export default function createBaseTranslator<
return parts.filter((part) => part != null).join('.');
}

const cacheKey = joinPath([namespace, key, String(message)]);
const cacheKey = joinPath([locale, namespace, key, String(message)]);

let messageFormat;
if (cachedFormatsByLocale?.[locale]?.[cacheKey]) {
messageFormat = cachedFormatsByLocale?.[locale][cacheKey];
let messageFormat: IntlMessageFormat;
if (messageFormatCache?.has(cacheKey)) {
messageFormat = messageFormatCache.get(cacheKey)!;
} else {
if (typeof message === 'object') {
let code, errorMessage;
Expand All @@ -214,6 +232,10 @@ export default function createBaseTranslator<
return getFallbackFromErrorAndNotify(key, code, errorMessage);
}

// Hot path that avoids creating an `IntlMessageFormat` instance
const plainMessage = getPlainMessage(message as string, values);
if (plainMessage) return plainMessage;

try {
messageFormat = new IntlMessageFormat(
message,
Expand All @@ -231,12 +253,7 @@ export default function createBaseTranslator<
);
}

if (cachedFormatsByLocale) {
if (!cachedFormatsByLocale[locale]) {
cachedFormatsByLocale[locale] = {};
}
cachedFormatsByLocale[locale][cacheKey] = messageFormat;
}
messageFormatCache?.set(cacheKey, messageFormat);
}

try {
Expand Down
8 changes: 7 additions & 1 deletion packages/use-intl/src/react/IntlContext.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,12 @@
import {createContext} from 'react';
import {InitializedIntlConfig} from '../core/IntlConfig';
import MessageFormatCache from '../core/MessageFormatCache';

const IntlContext = createContext<InitializedIntlConfig | undefined>(undefined);
const IntlContext = createContext<
| (InitializedIntlConfig & {
messageFormatCache?: MessageFormatCache;
})
| undefined
>(undefined);

export default IntlContext;
11 changes: 9 additions & 2 deletions packages/use-intl/src/react/IntlProvider.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import React, {ReactNode} from 'react';
import React, {ReactNode, useState} from 'react';
import IntlConfig from '../core/IntlConfig';
import IntlContext from './IntlContext';
import getInitializedConfig from './getInitializedConfig';
Expand All @@ -8,8 +8,15 @@ type Props = IntlConfig & {
};

export default function IntlProvider({children, ...props}: Props) {
const [messageFormatCache] = useState(() => new Map());

return (
<IntlContext.Provider value={getInitializedConfig(props)}>
<IntlContext.Provider
value={{
...getInitializedConfig(props),
messageFormatCache
}}
>
{children}
</IntlContext.Provider>
);
Expand Down
12 changes: 4 additions & 8 deletions packages/use-intl/src/react/useTranslationsImpl.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,4 @@
// eslint-disable-next-line import/no-named-as-default -- False positive
import IntlMessageFormat from 'intl-messageformat';
import {useMemo, useRef} from 'react';
import {useMemo} from 'react';
import AbstractIntlMessages from '../core/AbstractIntlMessages';
import createBaseTranslator, {
getMessagesOrError
Expand All @@ -18,6 +16,7 @@ export default function useTranslationsImpl<
formats: globalFormats,
getMessageFallback,
locale,
messageFormatCache,
onError,
timeZone
} = useIntlContext();
Expand All @@ -27,10 +26,6 @@ export default function useTranslationsImpl<
allMessages = allMessages[namespacePrefix] as Messages;
namespace = resolveNamespace(namespace, namespacePrefix) as NestedKey;

const cachedFormatsByLocaleRef = useRef<
Record<string, Record<string, IntlMessageFormat>>
>({});

const messagesOrError = useMemo(
() => getMessagesOrError({messages: allMessages, namespace, onError}),
[allMessages, namespace, onError]
Expand All @@ -39,7 +34,7 @@ export default function useTranslationsImpl<
const translate = useMemo(
() =>
createBaseTranslator({
cachedFormatsByLocale: cachedFormatsByLocaleRef.current,
messageFormatCache,
getMessageFallback,
messagesOrError,
defaultTranslationValues,
Expand All @@ -50,6 +45,7 @@ export default function useTranslationsImpl<
timeZone
}),
[
messageFormatCache,
getMessageFallback,
messagesOrError,
defaultTranslationValues,
Expand Down
Loading

3 comments on commit 4d177f8

@vercel
Copy link

@vercel vercel bot commented on 4d177f8 Aug 23, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Successfully deployed to the following URLs:

next-intl-example-next-13 – ./examples/example-next-13

next-intl-example-next-13-next-intl.vercel.app
next-intl-example-next-13.vercel.app
next-intl-example-next-13-git-main-next-intl.vercel.app

@vercel
Copy link

@vercel vercel bot commented on 4d177f8 Aug 23, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Successfully deployed to the following URLs:

example-next-13-next-auth – ./examples/example-next-13-next-auth

example-next-13-next-auth-next-intl.vercel.app
example-next-13-next-auth.vercel.app
example-next-13-next-auth-git-main-next-intl.vercel.app

@vercel
Copy link

@vercel vercel bot commented on 4d177f8 Aug 23, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Successfully deployed to the following URLs:

next-intl-docs – ./docs

next-intl-docs.vercel.app
next-intl-docs-next-intl.vercel.app
next-intl-docs-git-main-next-intl.vercel.app

Please sign in to comment.