Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Cache Intl.* constructors #1193

Merged
merged 9 commits into from
Jul 12, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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"
Copy link
Owner Author

Choose a reason for hiding this comment

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

I've added a note to #779 to make sure we target modern browsers, currently we're compiling too much syntax (e.g. rest params).

},
{
"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",
Copy link
Owner Author

Choose a reason for hiding this comment

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

Already in use due to intl-messageformat

"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
Loading