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

fix: Improve performance when calling hooks like useTranslations in Server Components by making sure we only suspend when i18n config is loaded initially and never for subsequent calls #741

Merged
merged 4 commits into from
Dec 20, 2023
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
2 changes: 2 additions & 0 deletions docs/pages/docs/environments/server-client-components.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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).

</details>

## Using internationalization in Client Components
Expand Down
49 changes: 49 additions & 0 deletions packages/next-intl/__mocks__/react.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
// @ts-expect-error -- React uses CJS
export * from 'react';

export {default} from 'react';

export function use(promise: Promise<unknown> & {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<string, unknown>;

export function cache(fn: (...args: Array<unknown>) => unknown) {
if (!fn.name) {
throw new Error('Expected a named function for easier debugging');
}

function cachedFn(...args: Array<unknown>) {
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];
});
};
4 changes: 2 additions & 2 deletions packages/next-intl/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -130,7 +130,7 @@
},
{
"path": "dist/production/server.react-server.js",
"limit": "12.8 KB"
"limit": "12.82 KB"
},
{
"path": "dist/production/middleware.js",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<IntlMessages>
> = never
>(
locale: string,
config: Parameters<typeof createTranslator>[0],
namespace?: NestedKey
): // Explicitly defining the return type is necessary as TypeScript would get it wrong
Promise<{
{
// Default invocation
<
TargetKey extends MessageKeys<
Expand Down Expand Up @@ -101,13 +103,11 @@ Promise<{
>(
key: TargetKey
): any;
}> {
const config = await getConfig(locale);
} {
return createTranslator({
...config,
messageFormatCache: getMessageFormatCache(),
namespace,
messages: config.messages
namespace
});
}

Expand Down
Original file line number Diff line number Diff line change
@@ -1,9 +1,8 @@
import {use} from 'react';
import getConfig from '../server/react-server/getConfig';
import useLocale from './useLocale';

export default function useHook<Value>(
hookName: string,
promise: Promise<Value>
) {
function useHook<Value>(hookName: string, promise: Promise<Value>) {
try {
return use(promise);
} catch (error: any) {
Expand All @@ -20,3 +19,10 @@ export default function useHook<Value>(
}
}
}

export default function useConfig(
hookName: string
): Awaited<ReturnType<typeof getConfig>> {
const locale = useLocale();
return useHook(hookName, getConfig(locale));
}
14 changes: 8 additions & 6 deletions packages/next-intl/src/react-server/useFormatter.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
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 {type useFormatter as useFormatterType} from 'use-intl';
import {createFormatter} from 'use-intl/core';
import useConfig from './useConfig';

const createFormatterCached = cache(createFormatter);

export default function useFormatter(
// eslint-disable-next-line no-empty-pattern
...[]: Parameters<typeof useFormatterType>
): ReturnType<typeof useFormatterType> {
const locale = useLocale();
return useHook('useFormatter', getFormatter({locale}));
const config = useConfig('useFormatter');
return createFormatterCached(config);
}
9 changes: 4 additions & 5 deletions packages/next-intl/src/react-server/useMessages.tsx
Original file line number Diff line number Diff line change
@@ -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<typeof useMessagesType>
): ReturnType<typeof useMessagesType> {
const locale = useLocale();
return useHook('useMessages', getMessages({locale}));
const config = useConfig('useMessages');
return getMessagesFromConfig(config);
}
8 changes: 3 additions & 5 deletions packages/next-intl/src/react-server/useNow.tsx
Original file line number Diff line number Diff line change
@@ -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<typeof useNowType>
Expand All @@ -12,6 +10,6 @@ export default function useNow(
);
}

const locale = useLocale();
return useHook('useNow', getNow({locale}));
const config = useConfig('useNow');
return config.now;
}
8 changes: 3 additions & 5 deletions packages/next-intl/src/react-server/useTimeZone.tsx
Original file line number Diff line number Diff line change
@@ -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<typeof useTimeZoneType>
): ReturnType<typeof useTimeZoneType> {
const locale = useLocale();
return useHook('useTimeZone', getTimeZone({locale}));
const config = useConfig('useTimeZone');
return config.timeZone;
}
15 changes: 4 additions & 11 deletions packages/next-intl/src/react-server/useTranslations.tsx
Original file line number Diff line number Diff line change
@@ -1,17 +1,10 @@
import type {useTranslations as useTranslationsType} from 'use-intl';
import getBaseTranslator from './getBaseTranslator';
import useHook from './useHook';
import useLocale from './useLocale';
import getBaseTranslator from './getTranslator';
import useConfig from './useConfig';

export default function useTranslations(
...[namespace]: Parameters<typeof useTranslationsType>
): ReturnType<typeof useTranslationsType> {
const locale = useLocale();

const result = useHook(
'useTranslations',
getBaseTranslator(locale, namespace)
);

return result;
const config = useConfig('useTranslations');
return getBaseTranslator(config, namespace);
}
10 changes: 6 additions & 4 deletions packages/next-intl/src/server/react-server/RequestLocale.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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;
Expand Down
69 changes: 34 additions & 35 deletions packages/next-intl/src/server/react-server/getConfig.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<IntlConfig['getMessageFallback']>;
now: NonNullable<IntlConfig['now']>;
onError: NonNullable<IntlConfig['onError']>;
timeZone: NonNullable<IntlConfig['timeZone']>;
}
> => {
const runtimeConfig = await receiveRuntimeConfig(
locale,
createRequestConfig
);
const opts = {...runtimeConfig, locale};
return initializeConfig(opts);
async function getConfigImpl(locale: string): Promise<
IntlConfig & {
getMessageFallback: NonNullable<IntlConfig['getMessageFallback']>;
now: NonNullable<IntlConfig['now']>;
onError: NonNullable<IntlConfig['onError']>;
timeZone: NonNullable<IntlConfig['timeZone']>;
}
);

> {
const runtimeConfig = await receiveRuntimeConfig(locale, createRequestConfig);
const opts = {...runtimeConfig, locale};
return initializeConfig(opts);
}
const getConfig = cache(getConfigImpl);
export default getConfig;
7 changes: 4 additions & 3 deletions packages/next-intl/src/server/react-server/getFormatter.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -18,5 +19,5 @@ export default async function getFormatter(opts?: {
locale?: string;
}): Promise<ReturnType<typeof createFormatter>> {
const locale = await resolveLocaleArg(opts);
return getFormatterImpl(locale);
return getFormatterCached(locale);
}
Loading
Loading