Skip to content

Commit

Permalink
feat: Cache Intl.* constructors (#1193)
Browse files Browse the repository at this point in the history
Fixes #215
  • Loading branch information
amannn authored Jul 12, 2024
1 parent 44a87a4 commit 52c4f2c
Show file tree
Hide file tree
Showing 23 changed files with 467 additions and 153 deletions.
8 changes: 4 additions & 4 deletions packages/next-intl/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -120,27 +120,27 @@
"size-limit": [
{
"path": "dist/production/index.react-client.js",
"limit": "15.785 KB"
"limit": "16.055 KB"
},
{
"path": "dist/production/index.react-server.js",
"limit": "16.66 KB"
"limit": "16.875 KB"
},
{
"path": "dist/production/navigation.react-client.js",
"limit": "3.465 KB"
},
{
"path": "dist/production/navigation.react-server.js",
"limit": "18.075 KB"
"limit": "18.325 KB"
},
{
"path": "dist/production/server.react-client.js",
"limit": "1 KB"
},
{
"path": "dist/production/server.react-server.js",
"limit": "15.84 KB"
"limit": "16.025 KB"
},
{
"path": "dist/production/middleware.js",
Expand Down
2 changes: 0 additions & 2 deletions packages/next-intl/src/react-server/getTranslator.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@ import {
createTranslator,
MarkupTranslationValues
} from 'use-intl/core';
import {getMessageFormatCache} from '../shared/messageFormatCache';

function getTranslatorImpl<
NestedKey extends NamespaceKeys<
Expand Down Expand Up @@ -102,7 +101,6 @@ function getTranslatorImpl<
} {
return createTranslator({
...config,
messageFormatCache: getMessageFormatCache(),
namespace
});
}
Expand Down
24 changes: 16 additions & 8 deletions packages/next-intl/src/react-server/index.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -72,19 +72,27 @@ describe('performance', () => {
expect(renderCount).toBe(3);
});

it('shares message format cache between useTranslations and getTranslations', async () => {
it('shares a formatter cache between `useTranslations` and `getTranslations`', async () => {
// First invocation
useTranslations('Component');
const firstCallCache =
vi.mocked(createTranslator).mock.calls[0][0].messageFormatCache;
// (simulate React rendering)
try {
useTranslations('Component');
} catch (promiseOrError) {
if (promiseOrError instanceof Promise) {
await promiseOrError;
useTranslations('Component');
} else {
throw promiseOrError;
}
}

// Second invocation with a different namespace
await getTranslations('Component2');
const secondCallCache =
vi.mocked(createTranslator).mock.calls[1][0].messageFormatCache;

// Verify that the same cache instance is used in both invocations
expect(firstCallCache).toBe(secondCallCache);
// Verify the cached formatters are shared
expect(vi.mocked(createTranslator).mock.calls[0][0]._formatters).toBe(
vi.mocked(createTranslator).mock.calls[1][0]._formatters
);
expect(vi.mocked(createTranslator).mock.calls.length).toBe(2);

vi.mocked(createTranslator).mockReset();
Expand Down
7 changes: 5 additions & 2 deletions packages/next-intl/src/server/react-server/getConfig.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import {cache} from 'react';
import {initializeConfig, IntlConfig} from 'use-intl/core';
import {initializeConfig, IntlConfig, _createFormatters} from 'use-intl/core';
import {getRequestLocale} from './RequestLocale';
import createRequestConfig from './createRequestConfig';

Expand Down Expand Up @@ -52,19 +52,22 @@ async function receiveRuntimeConfigImpl(
}
const receiveRuntimeConfig = cache(receiveRuntimeConfigImpl);

const getFormatters = cache(_createFormatters);

async function getConfigImpl(localeOverride?: string): Promise<
IntlConfig & {
getMessageFallback: NonNullable<IntlConfig['getMessageFallback']>;
now: NonNullable<IntlConfig['now']>;
onError: NonNullable<IntlConfig['onError']>;
timeZone: NonNullable<IntlConfig['timeZone']>;
_formatters: ReturnType<typeof _createFormatters>;
}
> {
const runtimeConfig = await receiveRuntimeConfig(
createRequestConfig,
localeOverride
);
return initializeConfig(runtimeConfig);
return {...initializeConfig(runtimeConfig), _formatters: getFormatters()};
}
const getConfig = cache(getConfigImpl);
export default getConfig;
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@ import {
RichTranslationValues,
MarkupTranslationValues
} from 'use-intl/core';
import {getMessageFormatCache} from '../../shared/messageFormatCache';
import getConfig from './getConfig';

// Maintainer note: `getTranslations` has two different call signatures.
Expand Down Expand Up @@ -215,7 +214,6 @@ async function getTranslations<

return createTranslator({
...config,
messageFormatCache: getMessageFormatCache(),
namespace,
messages: config.messages
});
Expand Down
6 changes: 0 additions & 6 deletions packages/next-intl/src/shared/messageFormatCache.tsx

This file was deleted.

10 changes: 9 additions & 1 deletion packages/use-intl/.eslintrc.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,5 +2,13 @@ module.exports = {
extends: ['molindo/typescript', 'molindo/react'],
rules: {
'import/no-useless-path-segments': 'error'
}
},
overrides: [
{
files: ['*.test.tsx'],
rules: {
'import/no-extraneous-dependencies': 'off'
}
}
]
};
4 changes: 3 additions & 1 deletion packages/use-intl/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@
"formatting"
],
"dependencies": {
"@formatjs/fast-memoize": "^2.2.0",
"intl-messageformat": "^10.5.14"
},
"peerDependencies": {
Expand All @@ -83,14 +84,15 @@
"react-dom": "^18.3.1",
"rollup": "^4.18.0",
"size-limit": "^8.2.6",
"tinyspy": "^3.0.0",
"typescript": "^5.4.5",
"vitest": "^1.6.0"
},
"prettier": "../../.prettierrc.json",
"size-limit": [
{
"path": "dist/production/index.js",
"limit": "15.28 kB"
"limit": "15.545 kB"
}
]
}
10 changes: 0 additions & 10 deletions packages/use-intl/src/core/MessageFormatCache.tsx

This file was deleted.

132 changes: 60 additions & 72 deletions packages/use-intl/src/core/createBaseTranslator.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,13 +11,13 @@ import AbstractIntlMessages from './AbstractIntlMessages';
import Formats from './Formats';
import {InitializedIntlConfig} from './IntlConfig';
import IntlError, {IntlErrorCode} from './IntlError';
import MessageFormatCache from './MessageFormatCache';
import TranslationValues, {
MarkupTranslationValues,
RichTranslationValues
} from './TranslationValues';
import convertFormatsToIntlMessageFormat from './convertFormatsToIntlMessageFormat';
import {defaultGetMessageFallback, defaultOnError} from './defaults';
import {Formatters} from './formatters';
import joinPath from './joinPath';
import MessageKeys from './utils/MessageKeys';
import NestedKeyOf from './utils/NestedKeyOf';
Expand Down Expand Up @@ -125,7 +125,7 @@ function getMessagesOrError<Messages extends AbstractIntlMessages>(
}

export type CreateBaseTranslatorProps<Messages> = InitializedIntlConfig & {
messageFormatCache?: MessageFormatCache;
formatters: Formatters;
defaultTranslationValues?: RichTranslationValues;
namespace?: string;
messagesOrError: Messages | IntlError;
Expand Down Expand Up @@ -171,9 +171,9 @@ function createBaseTranslatorImpl<
>({
defaultTranslationValues,
formats: globalFormats,
formatters,
getMessageFallback = defaultGetMessageFallback,
locale,
messageFormatCache,
messagesOrError,
namespace,
onError,
Expand Down Expand Up @@ -218,81 +218,69 @@ function createBaseTranslatorImpl<
);
}

const cacheKey = joinPath(locale, namespace, key, String(message));
if (typeof message === 'object') {
let code, errorMessage;
if (Array.isArray(message)) {
code = IntlErrorCode.INVALID_MESSAGE;
if (process.env.NODE_ENV !== 'production') {
errorMessage = `Message at \`${joinPath(
namespace,
key
)}\` resolved to an array, but only strings are supported. See https://next-intl-docs.vercel.app/docs/usage/messages#arrays-of-messages`;
}
} else {
code = IntlErrorCode.INSUFFICIENT_PATH;
if (process.env.NODE_ENV !== 'production') {
errorMessage = `Message at \`${joinPath(
namespace,
key
)}\` resolved to an object, but only strings are supported. Use a \`.\` to retrieve nested messages. See https://next-intl-docs.vercel.app/docs/usage/messages#structuring-messages`;
}
}

return getFallbackFromErrorAndNotify(key, code, errorMessage);
}

let messageFormat: IntlMessageFormat;
if (messageFormatCache?.has(cacheKey)) {
messageFormat = messageFormatCache.get(cacheKey)!;
} else {
if (typeof message === 'object') {
let code, errorMessage;
if (Array.isArray(message)) {
code = IntlErrorCode.INVALID_MESSAGE;
if (process.env.NODE_ENV !== 'production') {
errorMessage = `Message at \`${joinPath(
namespace,
key
)}\` resolved to an array, but only strings are supported. See https://next-intl-docs.vercel.app/docs/usage/messages#arrays-of-messages`;
}
} else {
code = IntlErrorCode.INSUFFICIENT_PATH;
if (process.env.NODE_ENV !== 'production') {
errorMessage = `Message at \`${joinPath(
namespace,
key
)}\` resolved to an object, but only strings are supported. Use a \`.\` to retrieve nested messages. See https://next-intl-docs.vercel.app/docs/usage/messages#structuring-messages`;
}
}

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

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

try {
messageFormat = new IntlMessageFormat(
message,
locale,
convertFormatsToIntlMessageFormat(
{...globalFormats, ...formats},
timeZone
),
{
formatters: {
getNumberFormat(locales, options) {
return new Intl.NumberFormat(
locales,
// `useGrouping` was changed from a boolean later to a string enum or boolean, the type definition is outdated (https://tc39.es/proposal-intl-numberformat-v3/#grouping-enum-ecma-402-367)
options as Intl.NumberFormatOptions
);
},
getDateTimeFormat(locales, options) {
// Workaround for https://github.com/formatjs/formatjs/issues/4279
return new Intl.DateTimeFormat(locales, {timeZone, ...options});
},
getPluralRules(locales, options) {
return new Intl.PluralRules(locales, options);
}
try {
messageFormat = formatters.getMessageFormat(
message,
locale,
convertFormatsToIntlMessageFormat(
{...globalFormats, ...formats},
timeZone
),
{
// @ts-expect-error -- TS is currently lacking support for ECMA-402 10.0 (`useGrouping: 'auto'`, see https://github.com/microsoft/TypeScript/issues/56269)
formatters: {
...formatters,
getDateTimeFormat(locales, options) {
// Workaround for https://github.com/formatjs/formatjs/issues/4279
return formatters.getDateTimeFormat(locales, {
timeZone,
...options
});
}
}
);
} catch (error) {
const thrownError = error as Error;
return getFallbackFromErrorAndNotify(
key,
IntlErrorCode.INVALID_MESSAGE,
process.env.NODE_ENV !== 'production'
? thrownError.message +
('originalMessage' in thrownError
? ` (${thrownError.originalMessage})`
: '')
: thrownError.message
);
}

messageFormatCache?.set(cacheKey, messageFormat);
}
);
} catch (error) {
const thrownError = error as Error;
return getFallbackFromErrorAndNotify(
key,
IntlErrorCode.INVALID_MESSAGE,
process.env.NODE_ENV !== 'production'
? thrownError.message +
('originalMessage' in thrownError
? ` (${thrownError.originalMessage})`
: '')
: thrownError.message
);
}

try {
Expand Down
Loading

0 comments on commit 52c4f2c

Please sign in to comment.