From 8054a3d1ccb95d568106d56b8a3de4e2415dca5c Mon Sep 17 00:00:00 2001 From: Jan Amann Date: Wed, 20 Dec 2023 13:40:30 +0100 Subject: [PATCH 1/4] fix: Improve performance when calling `useTranslations` in Server Components --- packages/next-intl/__mocks__/react.tsx | 49 ++++ ...etBaseTranslator.tsx => getTranslator.tsx} | 18 +- .../src/react-server/useTranslations.tsx | 12 +- .../src/server/react-server/RequestLocale.tsx | 10 +- .../src/server/react-server/getConfig.tsx | 69 +++-- .../src/server/react-server/getFormatter.tsx | 7 +- .../src/server/react-server/getMessages.tsx | 7 +- .../src/server/react-server/getNow.tsx | 7 +- .../src/server/react-server/getTimeZone.tsx | 7 +- ...reateLocalizedPathnamesNavigation.test.tsx | 7 +- .../createSharedPathnamesNavigation.test.tsx | 7 +- .../react-server/useTranslations.test.tsx | 253 ++++++++++++++++++ .../test/server/react-server/index.test.tsx | 10 +- packages/next-intl/tsconfig.json | 2 +- .../use-intl/src/core/createTranslator.tsx | 2 +- 15 files changed, 376 insertions(+), 91 deletions(-) create mode 100644 packages/next-intl/__mocks__/react.tsx rename packages/next-intl/src/react-server/{getBaseTranslator.tsx => getTranslator.tsx} (89%) create mode 100644 packages/next-intl/test/react-server/useTranslations.test.tsx diff --git a/packages/next-intl/__mocks__/react.tsx b/packages/next-intl/__mocks__/react.tsx new file mode 100644 index 000000000..029990321 --- /dev/null +++ b/packages/next-intl/__mocks__/react.tsx @@ -0,0 +1,49 @@ +// @ts-expect-error -- React uses CJS +export * from 'react'; + +export {default} from 'react'; + +export function use(promise: Promise & {value?: unknown}) { + if (!(promise instanceof Promise)) { + throw new Error('Expected a promise, got ' + typeof promise); + } + + if (promise.value) { + return promise.value; + } else { + throw promise.then((value) => { + promise.value = value; + return promise; + }); + } +} + +const cached = {} as Record; + +export function cache(fn: (...args: Array) => unknown) { + if (!fn.name) { + throw new Error('Expected a named function for easier debugging'); + } + + function cachedFn(...args: Array) { + const key = `${fn.name}(${args + .map((arg) => JSON.stringify(arg)) + .join(', ')})`; + + if (cached[key]) { + return cached[key]; + } else { + const result = fn(...args); + cached[key] = result; + return result; + } + } + + return cachedFn; +} + +cache.reset = () => { + Object.keys(cached).forEach((key) => { + delete cached[key]; + }); +}; diff --git a/packages/next-intl/src/react-server/getBaseTranslator.tsx b/packages/next-intl/src/react-server/getTranslator.tsx similarity index 89% rename from packages/next-intl/src/react-server/getBaseTranslator.tsx rename to packages/next-intl/src/react-server/getTranslator.tsx index facb512ae..8cd723030 100644 --- a/packages/next-intl/src/react-server/getBaseTranslator.tsx +++ b/packages/next-intl/src/react-server/getTranslator.tsx @@ -10,20 +10,22 @@ import { createTranslator, MarkupTranslationValues } from 'use-intl/core'; -import getConfig from '../server/react-server/getConfig'; -const getMessageFormatCache = cache(() => new Map()); +function getMessageFormatCacheImpl() { + return new Map(); +} +const getMessageFormatCache = cache(getMessageFormatCacheImpl); -async function getTranslatorImpl< +function getTranslatorImpl< NestedKey extends NamespaceKeys< IntlMessages, NestedKeyOf > = never >( - locale: string, + config: Parameters[0], namespace?: NestedKey ): // Explicitly defining the return type is necessary as TypeScript would get it wrong -Promise<{ +{ // Default invocation < TargetKey extends MessageKeys< @@ -101,13 +103,11 @@ Promise<{ >( key: TargetKey ): any; -}> { - const config = await getConfig(locale); +} { return createTranslator({ ...config, messageFormatCache: getMessageFormatCache(), - namespace, - messages: config.messages + namespace }); } diff --git a/packages/next-intl/src/react-server/useTranslations.tsx b/packages/next-intl/src/react-server/useTranslations.tsx index 3a39ea91b..d7c2eff22 100644 --- a/packages/next-intl/src/react-server/useTranslations.tsx +++ b/packages/next-intl/src/react-server/useTranslations.tsx @@ -1,5 +1,6 @@ import type {useTranslations as useTranslationsType} from 'use-intl'; -import getBaseTranslator from './getBaseTranslator'; +import getConfig from '../server/react-server/getConfig'; +import getBaseTranslator from './getTranslator'; import useHook from './useHook'; import useLocale from './useLocale'; @@ -7,11 +8,6 @@ export default function useTranslations( ...[namespace]: Parameters ): ReturnType { const locale = useLocale(); - - const result = useHook( - 'useTranslations', - getBaseTranslator(locale, namespace) - ); - - return result; + const config = useHook('useTranslations', getConfig(locale)); + return getBaseTranslator(config, namespace); } diff --git a/packages/next-intl/src/server/react-server/RequestLocale.tsx b/packages/next-intl/src/server/react-server/RequestLocale.tsx index 1dc8f8fd2..791fba88b 100644 --- a/packages/next-intl/src/server/react-server/RequestLocale.tsx +++ b/packages/next-intl/src/server/react-server/RequestLocale.tsx @@ -2,7 +2,7 @@ import {headers} from 'next/headers'; import {cache} from 'react'; import {HEADER_LOCALE_NAME} from '../../shared/constants'; -const getLocaleFromHeader = cache(() => { +function getLocaleFromHeaderImpl() { let locale; try { @@ -28,13 +28,15 @@ const getLocaleFromHeader = cache(() => { } return locale; -}); +} +const getLocaleFromHeader = cache(getLocaleFromHeaderImpl); // Workaround until `createServerContext` is available -const getCache = cache(() => { +function getCacheImpl() { const value: {locale?: string} = {locale: undefined}; return value; -}); +} +const getCache = cache(getCacheImpl); export function setRequestLocale(locale: string) { getCache().locale = locale; diff --git a/packages/next-intl/src/server/react-server/getConfig.tsx b/packages/next-intl/src/server/react-server/getConfig.tsx index 51dc8ed53..7872415aa 100644 --- a/packages/next-intl/src/server/react-server/getConfig.tsx +++ b/packages/next-intl/src/server/react-server/getConfig.tsx @@ -3,46 +3,45 @@ import {initializeConfig, IntlConfig} from 'use-intl/core'; import createRequestConfig from './createRequestConfig'; // Make sure `now` is consistent across the request in case none was configured -const getDefaultNow = cache(() => new Date()); +function getDefaultNowImpl() { + return new Date(); +} +const getDefaultNow = cache(getDefaultNowImpl); // This is automatically inherited by `NextIntlClientProvider` if // the component is rendered from a Server Component -const getDefaultTimeZone = cache( - () => Intl.DateTimeFormat().resolvedOptions().timeZone -); +function getDefaultTimeZoneImpl() { + return Intl.DateTimeFormat().resolvedOptions().timeZone; +} +const getDefaultTimeZone = cache(getDefaultTimeZoneImpl); -const receiveRuntimeConfig = cache( - async (locale: string, getConfig?: typeof createRequestConfig) => { - let result = getConfig?.({locale}); - if (result instanceof Promise) { - result = await result; - } - return { - ...result, - now: result?.now || getDefaultNow(), - timeZone: result?.timeZone || getDefaultTimeZone() - }; +async function receiveRuntimeConfigImpl( + locale: string, + getConfig?: typeof createRequestConfig +) { + let result = getConfig?.({locale}); + if (result instanceof Promise) { + result = await result; } -); + return { + ...result, + now: result?.now || getDefaultNow(), + timeZone: result?.timeZone || getDefaultTimeZone() + }; +} +const receiveRuntimeConfig = cache(receiveRuntimeConfigImpl); -const getConfig = cache( - async ( - locale: string - ): Promise< - IntlConfig & { - getMessageFallback: NonNullable; - now: NonNullable; - onError: NonNullable; - timeZone: NonNullable; - } - > => { - const runtimeConfig = await receiveRuntimeConfig( - locale, - createRequestConfig - ); - const opts = {...runtimeConfig, locale}; - return initializeConfig(opts); +async function getConfigImpl(locale: string): Promise< + IntlConfig & { + getMessageFallback: NonNullable; + now: NonNullable; + onError: NonNullable; + timeZone: NonNullable; } -); - +> { + const runtimeConfig = await receiveRuntimeConfig(locale, createRequestConfig); + const opts = {...runtimeConfig, locale}; + return initializeConfig(opts); +} +const getConfig = cache(getConfigImpl); export default getConfig; diff --git a/packages/next-intl/src/server/react-server/getFormatter.tsx b/packages/next-intl/src/server/react-server/getFormatter.tsx index 692c62a98..f5a57f05f 100644 --- a/packages/next-intl/src/server/react-server/getFormatter.tsx +++ b/packages/next-intl/src/server/react-server/getFormatter.tsx @@ -3,10 +3,11 @@ import {createFormatter} from 'use-intl/core'; import getConfig from './getConfig'; import resolveLocaleArg from './resolveLocaleArg'; -const getFormatterImpl = cache(async (locale: string) => { +async function getFormatterCachedImpl(locale: string) { const config = await getConfig(locale); return createFormatter(config); -}); +} +const getFormatterCached = cache(getFormatterCachedImpl); /** * Returns a formatter based on the given locale. @@ -18,5 +19,5 @@ export default async function getFormatter(opts?: { locale?: string; }): Promise> { const locale = await resolveLocaleArg(opts); - return getFormatterImpl(locale); + return getFormatterCached(locale); } diff --git a/packages/next-intl/src/server/react-server/getMessages.tsx b/packages/next-intl/src/server/react-server/getMessages.tsx index 07f83e5a9..caf26b2c3 100644 --- a/packages/next-intl/src/server/react-server/getMessages.tsx +++ b/packages/next-intl/src/server/react-server/getMessages.tsx @@ -3,7 +3,7 @@ import type {AbstractIntlMessages} from 'use-intl'; import getConfig from './getConfig'; import resolveLocaleArg from './resolveLocaleArg'; -const getMessagesImpl = cache(async (locale: string) => { +async function getMessagesCachedImpl(locale: string) { const config = await getConfig(locale); if (!config.messages) { @@ -13,11 +13,12 @@ const getMessagesImpl = cache(async (locale: string) => { } return config.messages; -}); +} +const getMessagesCached = cache(getMessagesCachedImpl); export default async function getMessages(opts?: { locale?: string; }): Promise { const locale = await resolveLocaleArg(opts); - return getMessagesImpl(locale); + return getMessagesCached(locale); } diff --git a/packages/next-intl/src/server/react-server/getNow.tsx b/packages/next-intl/src/server/react-server/getNow.tsx index 4c6013016..7969a81cd 100644 --- a/packages/next-intl/src/server/react-server/getNow.tsx +++ b/packages/next-intl/src/server/react-server/getNow.tsx @@ -2,12 +2,13 @@ import {cache} from 'react'; import getConfig from './getConfig'; import resolveLocaleArg from './resolveLocaleArg'; -const getNowImpl = cache(async (locale: string) => { +async function getNowCachedImpl(locale: string) { const config = await getConfig(locale); return config.now; -}); +} +const getNowCached = cache(getNowCachedImpl); export default async function getNow(opts?: {locale?: string}): Promise { const locale = await resolveLocaleArg(opts); - return getNowImpl(locale); + return getNowCached(locale); } diff --git a/packages/next-intl/src/server/react-server/getTimeZone.tsx b/packages/next-intl/src/server/react-server/getTimeZone.tsx index fa6acb4d2..1b68927c0 100644 --- a/packages/next-intl/src/server/react-server/getTimeZone.tsx +++ b/packages/next-intl/src/server/react-server/getTimeZone.tsx @@ -2,14 +2,15 @@ import {cache} from 'react'; import getConfig from './getConfig'; import resolveLocaleArg from './resolveLocaleArg'; -const getTimeZoneImpl = cache(async (locale: string) => { +async function getTimeZoneCachedImpl(locale: string) { const config = await getConfig(locale); return config.timeZone; -}); +} +const getTimeZoneCached = cache(getTimeZoneCachedImpl); export default async function getTimeZone(opts?: { locale?: string; }): Promise { const locale = await resolveLocaleArg(opts); - return getTimeZoneImpl(locale); + return getTimeZoneCached(locale); } diff --git a/packages/next-intl/test/navigation/createLocalizedPathnamesNavigation.test.tsx b/packages/next-intl/test/navigation/createLocalizedPathnamesNavigation.test.tsx index 6bec15f5e..a26d9f671 100644 --- a/packages/next-intl/test/navigation/createLocalizedPathnamesNavigation.test.tsx +++ b/packages/next-intl/test/navigation/createLocalizedPathnamesNavigation.test.tsx @@ -29,12 +29,7 @@ vi.mock('next-intl/config', () => ({ locale: 'en' }) })); -vi.mock('react', async (importOriginal) => ({ - ...((await importOriginal()) as typeof import('react')), - cache(fn: (...args: Array) => unknown) { - return (...args: Array) => fn(...args); - } -})); +vi.mock('react'); // Avoids handling an async component (not supported by renderToString) vi.mock('../../src/navigation/react-server/ServerLink', () => ({ default({locale, ...rest}: any) { diff --git a/packages/next-intl/test/navigation/createSharedPathnamesNavigation.test.tsx b/packages/next-intl/test/navigation/createSharedPathnamesNavigation.test.tsx index 0459538f5..2cf5b269e 100644 --- a/packages/next-intl/test/navigation/createSharedPathnamesNavigation.test.tsx +++ b/packages/next-intl/test/navigation/createSharedPathnamesNavigation.test.tsx @@ -28,12 +28,7 @@ vi.mock('next-intl/config', () => ({ locale: 'en' }) })); -vi.mock('react', async (importOriginal) => ({ - ...((await importOriginal()) as typeof import('react')), - cache(fn: (...args: Array) => unknown) { - return (...args: Array) => fn(...args); - } -})); +vi.mock('react'); // Avoids handling an async component (not supported by renderToString) vi.mock('../../src/navigation/react-server/ServerLink', () => ({ default({locale, ...rest}: any) { diff --git a/packages/next-intl/test/react-server/useTranslations.test.tsx b/packages/next-intl/test/react-server/useTranslations.test.tsx new file mode 100644 index 000000000..05664ff02 --- /dev/null +++ b/packages/next-intl/test/react-server/useTranslations.test.tsx @@ -0,0 +1,253 @@ +import React, {Suspense, cache} from 'react'; +import {ReactDOMServerReadableStream} from 'react-dom/server'; +// @ts-expect-error -- Not available in types +import {renderToReadableStream as _renderToReadableStream} from 'react-dom/server.browser'; +import {describe, expect, it, vi, beforeEach} from 'vitest'; +import {createTranslator, useTranslations} from '../../src/react-server'; + +global.ReadableStream = + require('web-streams-polyfill/ponyfill/es6').ReadableStream; +global.TextEncoder = require('util').TextEncoder; + +const renderToReadableStream: typeof import('react-dom/server').renderToReadableStream = + _renderToReadableStream; + +vi.mock('../../src/server/react-server/createRequestConfig', () => ({ + default: async () => ({ + messages: { + A: { + title: 'A' + }, + B: { + title: 'B' + }, + C: { + title: 'C' + } + } + }) +})); + +vi.mock('../../src/server/react-server/RequestLocale', () => ({ + getRequestLocale: vi.fn(() => 'en') +})); + +vi.mock('react'); + +vi.mock('use-intl/core', async (importActual) => { + const actual: any = await importActual(); + const {createTranslator: actualCreateTranslator} = actual; + return { + ...actual, + createTranslator: vi.fn(actualCreateTranslator) + }; +}); + +async function readStream(stream: ReactDOMServerReadableStream) { + const reader = stream.getReader(); + let result = ''; + // eslint-disable-next-line no-constant-condition + while (true) { + const {done, value} = await reader.read(); + if (done) break; + result += Buffer.from(value).toString('utf8'); + } + return result; +} + +describe('performance', () => { + let attemptedRenders: Record; + let finishedRenders: Record; + + beforeEach(() => { + attemptedRenders = {}; + finishedRenders = {}; + (cache as any).reset(); + }); + + function attempt(componentName: string) { + attemptedRenders[componentName] ??= 0; + attemptedRenders[componentName]++; + } + + function finish(componentName: string) { + finishedRenders[componentName] ??= 0; + finishedRenders[componentName]++; + } + + it('suspends only once when rendering the same component twice (i.e. multiple `useTranslations` calls with the same namespace)', async () => { + function A({quit}: {quit?: boolean}) { + attempt('A'); + const t = useTranslations('A'); + finish('A'); + return ( + <> + {t('title')} + {!quit && } + + ); + } + + await readStream( + await renderToReadableStream( + + + + ) + ); + + expect({attemptedRenders, finishedRenders}).toMatchInlineSnapshot(` + { + "attemptedRenders": { + "A": 3, + }, + "finishedRenders": { + "A": 2, + }, + } + `); + }); + + it('suspends only once when rendering different components (i.e. multiple `useTranslations` calls with a different namespace)', async () => { + function A() { + attempt('A'); + const t = useTranslations('A'); + finish('A'); + return ( + <> + {t('title')} + + + ); + } + + function B() { + attempt('B'); + const t = useTranslations('B'); + finish('B'); + return t('title'); + } + + await readStream( + await renderToReadableStream( + + + + ) + ); + + expect({attemptedRenders, finishedRenders}).toMatchInlineSnapshot(` + { + "attemptedRenders": { + "A": 2, + "B": 1, + }, + "finishedRenders": { + "A": 1, + "B": 1, + }, + } + `); + }); + + it('resolves the config only once for a complex tree', async () => { + function A() { + attempt('A'); + const t = useTranslations('A'); + finish('A'); + return t('title'); + } + + function B() { + attempt('B'); + const t = useTranslations('B'); + finish('B'); + return t('title'); + } + + function C() { + attempt('C'); + const t = useTranslations(); + finish('C'); + return t('C.title'); + } + + function E() { + attempt('E'); + finish('E'); + return ; + } + + function D() { + attempt('D'); + finish('D'); + return ( + <> + + + + + + ); + } + + await readStream( + await renderToReadableStream( + + + + + + + ) + ); + + expect({attemptedRenders, finishedRenders}).toMatchInlineSnapshot(` + { + "attemptedRenders": { + "A": 6, + "B": 4, + "C": 4, + "D": 1, + "E": 1, + }, + "finishedRenders": { + "A": 3, + "B": 2, + "C": 2, + "D": 1, + "E": 1, + }, + } + `); + }); + + it('instantiates a single translator per namespace', async () => { + vi.mocked(createTranslator).mockImplementation(() => (() => 'Test') as any); + + function Component() { + useTranslations('CreateTranslatorInstancesTest-1'); + useTranslations('CreateTranslatorInstancesTest-1'); + useTranslations('CreateTranslatorInstancesTest-2'); + return null; + } + await readStream( + await renderToReadableStream( + <> + + + ) + ); + + function getCalls(namespace: string) { + return vi + .mocked(createTranslator) + .mock.calls.filter( + ([{namespace: _namespace}]) => _namespace === namespace + ); + } + + expect(getCalls('CreateTranslatorInstancesTest-1').length).toEqual(1); + expect(getCalls('CreateTranslatorInstancesTest-2').length).toEqual(1); + }); +}); diff --git a/packages/next-intl/test/server/react-server/index.test.tsx b/packages/next-intl/test/server/react-server/index.test.tsx index acda29819..33e68cb01 100644 --- a/packages/next-intl/test/server/react-server/index.test.tsx +++ b/packages/next-intl/test/server/react-server/index.test.tsx @@ -40,15 +40,7 @@ vi.mock('next/headers', () => ({ }) })); -vi.mock('react', async (importOriginal) => { - const React = (await importOriginal()) as typeof import('react'); - return { - ...React, - cache(fn: (...args: Array) => unknown) { - return (...args: Array) => fn(...args); - } - }; -}); +vi.mock('react'); describe('getTranslations', () => { it('works with an implicit locale', async () => { diff --git a/packages/next-intl/tsconfig.json b/packages/next-intl/tsconfig.json index 874f2d35a..a8c3d4ed6 100644 --- a/packages/next-intl/tsconfig.json +++ b/packages/next-intl/tsconfig.json @@ -1,6 +1,6 @@ { "extends": "eslint-config-molindo/tsconfig.json", - "include": ["src", "test", "types", "next-env.d.ts"], + "include": ["src", "test", "__mocks__", "types", "next-env.d.ts"], "compilerOptions": { "moduleDetection": "force", "isolatedModules": true, diff --git a/packages/use-intl/src/core/createTranslator.tsx b/packages/use-intl/src/core/createTranslator.tsx index 27d4620a6..a9d29756e 100644 --- a/packages/use-intl/src/core/createTranslator.tsx +++ b/packages/use-intl/src/core/createTranslator.tsx @@ -33,7 +33,7 @@ export default function createTranslator< onError = defaultOnError, ...rest }: Omit, 'defaultTranslationValues' | 'messages'> & { - messages: IntlConfig['messages']; + messages?: IntlConfig['messages']; namespace?: NestedKey; /** @private */ messageFormatCache?: MessageFormatCache; From 1f5791bd3db5330563d21874f7da2702589effc9 Mon Sep 17 00:00:00 2001 From: Jan Amann Date: Wed, 20 Dec 2023 14:42:49 +0100 Subject: [PATCH 2/4] Apply improved handling to other hooks --- packages/next-intl/package.json | 4 +- .../{useHook.tsx => useConfig.tsx} | 14 ++-- .../src/react-server/useFormatter.tsx | 13 ++-- .../src/react-server/useMessages.tsx | 9 ++- .../next-intl/src/react-server/useNow.tsx | 8 +-- .../src/react-server/useTimeZone.tsx | 8 +-- .../src/react-server/useTranslations.tsx | 7 +- .../src/server/react-server/getMessages.tsx | 12 ++-- .../test/react-server/index.test.tsx | 60 +++++++++++++++++ .../react-server/useTranslations.test.tsx | 65 ++++--------------- .../next-intl/test/react-server/utils.tsx | 25 +++++++ 11 files changed, 136 insertions(+), 89 deletions(-) rename packages/next-intl/src/react-server/{useHook.tsx => useConfig.tsx} (59%) create mode 100644 packages/next-intl/test/react-server/index.test.tsx create mode 100644 packages/next-intl/test/react-server/utils.tsx diff --git a/packages/next-intl/package.json b/packages/next-intl/package.json index dcd08fd71..1a258d7c6 100644 --- a/packages/next-intl/package.json +++ b/packages/next-intl/package.json @@ -114,7 +114,7 @@ }, { "path": "dist/production/index.react-server.js", - "limit": "13.6 KB" + "limit": "14.15 KB" }, { "path": "dist/production/navigation.react-client.js", @@ -130,7 +130,7 @@ }, { "path": "dist/production/server.react-server.js", - "limit": "12.8 KB" + "limit": "12.82 KB" }, { "path": "dist/production/middleware.js", diff --git a/packages/next-intl/src/react-server/useHook.tsx b/packages/next-intl/src/react-server/useConfig.tsx similarity index 59% rename from packages/next-intl/src/react-server/useHook.tsx rename to packages/next-intl/src/react-server/useConfig.tsx index e19ceb533..5525de78e 100644 --- a/packages/next-intl/src/react-server/useHook.tsx +++ b/packages/next-intl/src/react-server/useConfig.tsx @@ -1,9 +1,8 @@ import {use} from 'react'; +import getConfig from '../server/react-server/getConfig'; +import useLocale from './useLocale'; -export default function useHook( - hookName: string, - promise: Promise -) { +function useHook(hookName: string, promise: Promise) { try { return use(promise); } catch (error: any) { @@ -20,3 +19,10 @@ export default function useHook( } } } + +export default function useConfig( + hookName: string +): Awaited> { + const locale = useLocale(); + return useHook(hookName, getConfig(locale)); +} diff --git a/packages/next-intl/src/react-server/useFormatter.tsx b/packages/next-intl/src/react-server/useFormatter.tsx index ea255fc96..974149595 100644 --- a/packages/next-intl/src/react-server/useFormatter.tsx +++ b/packages/next-intl/src/react-server/useFormatter.tsx @@ -1,12 +1,13 @@ -import type {useFormatter as useFormatterType} from 'use-intl'; -import getFormatter from '../server/react-server/getFormatter'; -import useHook from './useHook'; -import useLocale from './useLocale'; +import {cache} from 'react'; +import {createFormatter, type useFormatter as useFormatterType} from 'use-intl'; +import useConfig from './useConfig'; + +const createFormatterCached = cache(createFormatter); export default function useFormatter( // eslint-disable-next-line no-empty-pattern ...[]: Parameters ): ReturnType { - const locale = useLocale(); - return useHook('useFormatter', getFormatter({locale})); + const config = useConfig('useFormatter'); + return createFormatterCached(config); } diff --git a/packages/next-intl/src/react-server/useMessages.tsx b/packages/next-intl/src/react-server/useMessages.tsx index 0b213b608..8dff17598 100644 --- a/packages/next-intl/src/react-server/useMessages.tsx +++ b/packages/next-intl/src/react-server/useMessages.tsx @@ -1,12 +1,11 @@ import type {useMessages as useMessagesType} from 'use-intl'; -import getMessages from '../server/react-server/getMessages'; -import useHook from './useHook'; -import useLocale from './useLocale'; +import {getMessagesFromConfig} from '../server/react-server/getMessages'; +import useConfig from './useConfig'; export default function useMessages( // eslint-disable-next-line no-empty-pattern ...[]: Parameters ): ReturnType { - const locale = useLocale(); - return useHook('useMessages', getMessages({locale})); + const config = useConfig('useMessages'); + return getMessagesFromConfig(config); } diff --git a/packages/next-intl/src/react-server/useNow.tsx b/packages/next-intl/src/react-server/useNow.tsx index c29675d74..3b4c2411c 100644 --- a/packages/next-intl/src/react-server/useNow.tsx +++ b/packages/next-intl/src/react-server/useNow.tsx @@ -1,7 +1,5 @@ import type {useNow as useNowType} from 'use-intl'; -import getNow from '../server/react-server/getNow'; -import useHook from './useHook'; -import useLocale from './useLocale'; +import useConfig from './useConfig'; export default function useNow( ...[options]: Parameters @@ -12,6 +10,6 @@ export default function useNow( ); } - const locale = useLocale(); - return useHook('useNow', getNow({locale})); + const config = useConfig('useNow'); + return config.now; } diff --git a/packages/next-intl/src/react-server/useTimeZone.tsx b/packages/next-intl/src/react-server/useTimeZone.tsx index 86077117f..6b47cfe36 100644 --- a/packages/next-intl/src/react-server/useTimeZone.tsx +++ b/packages/next-intl/src/react-server/useTimeZone.tsx @@ -1,12 +1,10 @@ import type {useTimeZone as useTimeZoneType} from 'use-intl'; -import getTimeZone from '../server/react-server/getTimeZone'; -import useHook from './useHook'; -import useLocale from './useLocale'; +import useConfig from './useConfig'; export default function useTimeZone( // eslint-disable-next-line no-empty-pattern ...[]: Parameters ): ReturnType { - const locale = useLocale(); - return useHook('useTimeZone', getTimeZone({locale})); + const config = useConfig('useTimeZone'); + return config.timeZone; } diff --git a/packages/next-intl/src/react-server/useTranslations.tsx b/packages/next-intl/src/react-server/useTranslations.tsx index d7c2eff22..ebf7b613c 100644 --- a/packages/next-intl/src/react-server/useTranslations.tsx +++ b/packages/next-intl/src/react-server/useTranslations.tsx @@ -1,13 +1,10 @@ import type {useTranslations as useTranslationsType} from 'use-intl'; -import getConfig from '../server/react-server/getConfig'; import getBaseTranslator from './getTranslator'; -import useHook from './useHook'; -import useLocale from './useLocale'; +import useConfig from './useConfig'; export default function useTranslations( ...[namespace]: Parameters ): ReturnType { - const locale = useLocale(); - const config = useHook('useTranslations', getConfig(locale)); + const config = useConfig('useTranslations'); return getBaseTranslator(config, namespace); } diff --git a/packages/next-intl/src/server/react-server/getMessages.tsx b/packages/next-intl/src/server/react-server/getMessages.tsx index caf26b2c3..7591dc612 100644 --- a/packages/next-intl/src/server/react-server/getMessages.tsx +++ b/packages/next-intl/src/server/react-server/getMessages.tsx @@ -3,17 +3,21 @@ import type {AbstractIntlMessages} from 'use-intl'; import getConfig from './getConfig'; import resolveLocaleArg from './resolveLocaleArg'; -async function getMessagesCachedImpl(locale: string) { - const config = await getConfig(locale); - +export function getMessagesFromConfig( + config: Awaited> +): AbstractIntlMessages { if (!config.messages) { throw new Error( 'No messages found. Have you configured them correctly? See https://next-intl-docs.vercel.app/docs/configuration#messages' ); } - return config.messages; } + +async function getMessagesCachedImpl(locale: string) { + const config = await getConfig(locale); + return getMessagesFromConfig(config); +} const getMessagesCached = cache(getMessagesCachedImpl); export default async function getMessages(opts?: { diff --git a/packages/next-intl/test/react-server/index.test.tsx b/packages/next-intl/test/react-server/index.test.tsx new file mode 100644 index 000000000..193563084 --- /dev/null +++ b/packages/next-intl/test/react-server/index.test.tsx @@ -0,0 +1,60 @@ +import React from 'react'; +import {describe, expect, vi, it} from 'vitest'; +import { + useFormatter, + useLocale, + useMessages, + useNow, + useTranslations +} from '../../src/react-server'; +import {renderToStream} from './utils'; + +vi.mock('react'); + +vi.mock('../../src/server/react-server/createRequestConfig', () => ({ + default: async () => ({ + messages: { + Component: { + title: 'Title' + } + } + }) +})); + +vi.mock('../../src/server/react-server/RequestLocale', () => ({ + getRequestLocale: vi.fn(() => 'en') +})); + +describe('performance', () => { + it('suspends only once when using a mixture of hooks', async () => { + let renderCount = 0; + + function Component({quit}: {quit?: boolean}) { + renderCount++; + + const t = useTranslations('Component'); + const format = useFormatter(); + const locale = useLocale(); + const messages = useMessages(); + const now = useNow(); + + return ( + <> + {now.toISOString()} + {JSON.stringify(messages)} + {locale} + {format.number(1000)} + {t('title')} + {!quit && } + + ); + } + + await renderToStream(); + + // Render 1: Suspends when `useTranslations` is encountered + // Render 2: Synchronously renders through + // Render 3: Recursive call that renders synchronously as well + expect(renderCount).toBe(3); + }); +}); diff --git a/packages/next-intl/test/react-server/useTranslations.test.tsx b/packages/next-intl/test/react-server/useTranslations.test.tsx index 05664ff02..9c1ab43fa 100644 --- a/packages/next-intl/test/react-server/useTranslations.test.tsx +++ b/packages/next-intl/test/react-server/useTranslations.test.tsx @@ -1,16 +1,7 @@ -import React, {Suspense, cache} from 'react'; -import {ReactDOMServerReadableStream} from 'react-dom/server'; -// @ts-expect-error -- Not available in types -import {renderToReadableStream as _renderToReadableStream} from 'react-dom/server.browser'; +import React, {cache} from 'react'; import {describe, expect, it, vi, beforeEach} from 'vitest'; import {createTranslator, useTranslations} from '../../src/react-server'; - -global.ReadableStream = - require('web-streams-polyfill/ponyfill/es6').ReadableStream; -global.TextEncoder = require('util').TextEncoder; - -const renderToReadableStream: typeof import('react-dom/server').renderToReadableStream = - _renderToReadableStream; +import {renderToStream} from './utils'; vi.mock('../../src/server/react-server/createRequestConfig', () => ({ default: async () => ({ @@ -43,18 +34,6 @@ vi.mock('use-intl/core', async (importActual) => { }; }); -async function readStream(stream: ReactDOMServerReadableStream) { - const reader = stream.getReader(); - let result = ''; - // eslint-disable-next-line no-constant-condition - while (true) { - const {done, value} = await reader.read(); - if (done) break; - result += Buffer.from(value).toString('utf8'); - } - return result; -} - describe('performance', () => { let attemptedRenders: Record; let finishedRenders: Record; @@ -88,13 +67,7 @@ describe('performance', () => { ); } - await readStream( - await renderToReadableStream( - - - - ) - ); + await renderToStream(); expect({attemptedRenders, finishedRenders}).toMatchInlineSnapshot(` { @@ -128,13 +101,7 @@ describe('performance', () => { return t('title'); } - await readStream( - await renderToReadableStream( - - - - ) - ); + await renderToStream(); expect({attemptedRenders, finishedRenders}).toMatchInlineSnapshot(` { @@ -191,15 +158,13 @@ describe('performance', () => { ); } - await readStream( - await renderToReadableStream( - - - - - - - ) + await renderToStream( + <> + + + + + ); expect({attemptedRenders, finishedRenders}).toMatchInlineSnapshot(` @@ -231,13 +196,7 @@ describe('performance', () => { useTranslations('CreateTranslatorInstancesTest-2'); return null; } - await readStream( - await renderToReadableStream( - <> - - - ) - ); + await renderToStream(); function getCalls(namespace: string) { return vi diff --git a/packages/next-intl/test/react-server/utils.tsx b/packages/next-intl/test/react-server/utils.tsx new file mode 100644 index 000000000..73bf4cd48 --- /dev/null +++ b/packages/next-intl/test/react-server/utils.tsx @@ -0,0 +1,25 @@ +import React, {ReactNode, Suspense} from 'react'; +import {ReactDOMServerReadableStream} from 'react-dom/server'; +// @ts-expect-error -- Not available in types +import {renderToReadableStream as _renderToReadableStream} from 'react-dom/server.browser'; + +const renderToReadableStream: typeof import('react-dom/server').renderToReadableStream = + _renderToReadableStream; + +async function readStream(stream: ReactDOMServerReadableStream) { + const reader = stream.getReader(); + let result = ''; + // eslint-disable-next-line no-constant-condition + while (true) { + const {done, value} = await reader.read(); + if (done) break; + result += Buffer.from(value).toString('utf8'); + } + return result; +} + +export async function renderToStream(children: ReactNode) { + return readStream( + await renderToReadableStream({children}) + ); +} From abed911208b7581f6660b28ac77e071cc928c263 Mon Sep 17 00:00:00 2001 From: Jan Amann Date: Wed, 20 Dec 2023 14:48:23 +0100 Subject: [PATCH 3/4] Docs --- docs/pages/docs/environments/server-client-components.mdx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/pages/docs/environments/server-client-components.mdx b/docs/pages/docs/environments/server-client-components.mdx index c07d28929..e55e4adb2 100644 --- a/docs/pages/docs/environments/server-client-components.mdx +++ b/docs/pages/docs/environments/server-client-components.mdx @@ -117,6 +117,8 @@ If you implement components that qualify as shared components, it can be benefic However, there's no need to dogmatically use non-async functions exclusively for handling internationalization—use what fits your app best. +In regard to performance, async functions and hooks can be used very much interchangeably. The configuration from [`i18n.ts`](/docs/usage/configuration#i18nts) is only loaded once upon first usage and both implementations use request-based caching internally where relevant. The only minor difference is that async functions have the benefit that rendering can be resumed right after an async function has been invoked. In contrast, in case a hook call triggers the initialization in `i18n.ts`, the component will suspend until the config is resolved and will re-render subsequently, possibly re-executing component logic prior to the hook call. However, once config has been resolved as part of a request, hooks will execute synchronously without suspending, resulting in less overhead in comparison to async functions since rendering can be resumed without having to wait for the microtask queue to flush (see [resuming a suspended component by replaying its execution](https://github.com/acdlite/rfcs/blob/first-class-promises/text/0000-first-class-support-for-promises.md#resuming-a-suspended-component-by-replaying-its-execution) in the corresponding React RFC). + ## Using internationalization in Client Components From 2feb9604d350f6b1211c80dd231f2a790595d869 Mon Sep 17 00:00:00 2001 From: Jan Amann Date: Wed, 20 Dec 2023 14:57:25 +0100 Subject: [PATCH 4/4] Fix import --- packages/next-intl/src/react-server/useFormatter.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/next-intl/src/react-server/useFormatter.tsx b/packages/next-intl/src/react-server/useFormatter.tsx index 974149595..7aac95999 100644 --- a/packages/next-intl/src/react-server/useFormatter.tsx +++ b/packages/next-intl/src/react-server/useFormatter.tsx @@ -1,5 +1,6 @@ import {cache} from 'react'; -import {createFormatter, type useFormatter as useFormatterType} from 'use-intl'; +import {type useFormatter as useFormatterType} from 'use-intl'; +import {createFormatter} from 'use-intl/core'; import useConfig from './useConfig'; const createFormatterCached = cache(createFormatter);