Skip to content

Commit

Permalink
fix: Improve performance when calling hooks like useTranslations in…
Browse files Browse the repository at this point in the history
… Server Components by making sure we only suspend when i18n config is initially loaded and never for subsequent calls (#741)

Fixes #734 by making sure we
suspend only once when loading configuration.

See also
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
  • Loading branch information
amannn committed Dec 20, 2023
1 parent 8009a95 commit 4185a72
Show file tree
Hide file tree
Showing 24 changed files with 459 additions and 124 deletions.
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);
}

2 comments on commit 4185a72

@vercel
Copy link

@vercel vercel bot commented on 4185a72 Dec 20, 2023

Choose a reason for hiding this comment

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

@vercel
Copy link

@vercel vercel bot commented on 4185a72 Dec 20, 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.