diff --git a/docs/src/pages/docs/environments/server-client-components.mdx b/docs/src/pages/docs/environments/server-client-components.mdx index b7c743ef4..865e5201b 100644 --- a/docs/src/pages/docs/environments/server-client-components.mdx +++ b/docs/src/pages/docs/environments/server-client-components.mdx @@ -115,11 +115,13 @@ Regarding performance, async functions and hooks can be used interchangeably. Th ## Using internationalization in Client Components -Depending on your situation, you may need to handle internationalization in Client Components. While providing all messages to the client side is typically the easiest way to [get started](/docs/getting-started/app-router#layout) and a reasonable approach for many apps, you can be more selective about which messages are passed to the client side if you're interested in optimizing the performance of your app. +Depending on your situation, you may need to handle internationalization in Client Components. Providing all messages to the client side is the easiest way to get started, therefore `next-intl` automatically does this when you render [`NextIntlClientProvider`](/docs/usage/configuration#nextintlclientprovider). This is a reasonable approach for many apps. + +However, you can be more selective about which messages are passed to the client side if you're interested in optimizing the performance of your app. There are several options for using translations from `next-intl` in Client Components, listed here in order of enabling the best performance: -### Option 1: Passing translations to Client Components +### Option 1: Passing translated labels to Client Components The preferred approach is to pass the processed labels as props or `children` from a Server Component. @@ -278,8 +280,6 @@ In particular, page and search params are often a great option because they offe ### Option 3: Providing individual messages -To reduce bundle size, `next-intl` doesn't automatically provide [messages](/docs/usage/configuration#messages) to Client Components. - If you need to incorporate dynamic state into components that can not be moved to the server side, you can wrap these components with `NextIntlClientProvider` and provide the relevant messages. ```tsx filename="Counter.tsx" @@ -315,22 +315,16 @@ An automatic, compiler-driven approach is being evaluated in [`next-intl#1`](htt ### Option 4: Providing all messages -If you're building a highly dynamic app where most components use React's interactive features, you may prefer to make all messages available to Client Components. +If you're building a highly dynamic app where most components use React's interactive features, you may prefer to make all messages available to Client Components—this is the default behavior of `next-intl`. ```tsx filename="layout.tsx" /NextIntlClientProvider/ import {NextIntlClientProvider} from 'next-intl'; -import {getMessages} from 'next-intl/server'; export default async function RootLayout(/* ... */) { - // Receive messages provided in `i18n/request.ts` - const messages = await getMessages(); - return ( - - {children} - + {children} ); diff --git a/docs/src/pages/docs/getting-started/app-router/with-i18n-routing.mdx b/docs/src/pages/docs/getting-started/app-router/with-i18n-routing.mdx index 58f3e7b83..878c240c0 100644 --- a/docs/src/pages/docs/getting-started/app-router/with-i18n-routing.mdx +++ b/docs/src/pages/docs/getting-started/app-router/with-i18n-routing.mdx @@ -186,7 +186,6 @@ The `locale` that was matched by the middleware is available via the `locale` pa ```tsx filename="app/[locale]/layout.tsx" import {NextIntlClientProvider, Locale, hasLocale} from 'next-intl'; -import {getMessages} from 'next-intl/server'; import {notFound} from 'next/navigation'; import {routing} from '@/i18n/routing'; @@ -201,24 +200,16 @@ export default async function LocaleLayout({ notFound(); } - // Providing all messages to the client - // side is the easiest way to get started - const messages = await getMessages(); - return ( - - {children} - + {children} ); } ``` -Note that `NextIntlClientProvider` automatically inherits configuration from `i18n/request.ts` here, but `messages` need to be passed explicitly. - ### `src/app/[locale]/page.tsx` [#page] And that's it! diff --git a/docs/src/pages/docs/getting-started/app-router/without-i18n-routing.mdx b/docs/src/pages/docs/getting-started/app-router/without-i18n-routing.mdx index 3b43b6bc3..db9b2a3fa 100644 --- a/docs/src/pages/docs/getting-started/app-router/without-i18n-routing.mdx +++ b/docs/src/pages/docs/getting-started/app-router/without-i18n-routing.mdx @@ -129,7 +129,7 @@ The `locale` that was provided in `i18n/request.ts` is available via `getLocale` ```tsx filename="app/layout.tsx" import {NextIntlClientProvider} from 'next-intl'; -import {getLocale, getMessages} from 'next-intl/server'; +import {getLocale} from 'next-intl/server'; export default async function RootLayout({ children @@ -138,24 +138,16 @@ export default async function RootLayout({ }) { const locale = await getLocale(); - // Providing all messages to the client - // side is the easiest way to get started - const messages = await getMessages(); - return ( - - {children} - + {children} ); } ``` -Note that `NextIntlClientProvider` automatically inherits configuration from `i18n/request.ts` here, but `messages` need to be passed explicitly. - ### `app/page.tsx` [#page] Use translations in your page components or anywhere else! diff --git a/docs/src/pages/docs/usage/configuration.mdx b/docs/src/pages/docs/usage/configuration.mdx index d17394040..9236ba871 100644 --- a/docs/src/pages/docs/usage/configuration.mdx +++ b/docs/src/pages/docs/usage/configuration.mdx @@ -64,9 +64,7 @@ export default async function RootLayout(/* ... */) { return ( - - {children} - + {children} ); @@ -76,14 +74,15 @@ export default async function RootLayout(/* ... */) { These props are inherited if you're rendering `NextIntlClientProvider` from a Server Component: 1. `locale` -2. `now` -3. `timeZone` -4. `formats` +2. `messages` +3. `now` +4. `timeZone` +5. `formats` In contrast, these props can be provided as necessary: -1. `messages` (see [Internationalization in Client Components](/docs/environments/server-client-components#using-internationalization-in-client-components)) -2. `onError` and `getMessageFallback` +1. `onError` +2. `getMessageFallback` Additionally, nested instances of `NextIntlClientProvider` will inherit configuration from their respective ancestors. Note however that individual props are treated as atomic, therefore e.g. `messages` need to be merged manually—if necessary. @@ -115,17 +114,16 @@ Once you have defined your client-side provider component, you can use it in a S ```tsx filename="layout.tsx" import {NextIntlClientProvider} from 'next-intl'; -import {getLocale, getMessages} from 'next-intl/server'; +import {getLocale} from 'next-intl/server'; import IntlErrorHandlingProvider from './IntlErrorHandlingProvider'; export default async function RootLayout({children}) { const locale = await getLocale(); - const messages = await getMessages(); return ( - + {children} @@ -380,13 +378,14 @@ const messages = await getMessages(); ```tsx import {NextIntlClientProvider} from 'next-intl'; import {getMessages} from 'next-intl/server'; +import pick from 'lodash/pick'; async function Component({children}) { // Read messages configured via `i18n/request.ts` const messages = await getMessages(); return ( - + {children} ); diff --git a/examples/example-app-router-migration/src/app/[locale]/layout.tsx b/examples/example-app-router-migration/src/app/[locale]/layout.tsx index 5f131d8c9..400b13f71 100644 --- a/examples/example-app-router-migration/src/app/[locale]/layout.tsx +++ b/examples/example-app-router-migration/src/app/[locale]/layout.tsx @@ -1,6 +1,5 @@ import {notFound} from 'next/navigation'; import {NextIntlClientProvider, hasLocale} from 'next-intl'; -import {getMessages} from 'next-intl/server'; import {ReactNode} from 'react'; import {routing} from '@/i18n/routing'; @@ -14,19 +13,13 @@ export default async function LocaleLayout({children, params}: Props) { notFound(); } - // Providing all messages to the client - // side is the easiest way to get started - const messages = await getMessages(); - return ( next-intl - - {children} - + {children} ); diff --git a/examples/example-app-router-mixed-routing/src/app/(public)/[locale]/layout.tsx b/examples/example-app-router-mixed-routing/src/app/(public)/[locale]/layout.tsx index cde039f11..4fe3ae935 100644 --- a/examples/example-app-router-mixed-routing/src/app/(public)/[locale]/layout.tsx +++ b/examples/example-app-router-mixed-routing/src/app/(public)/[locale]/layout.tsx @@ -1,7 +1,7 @@ import {Metadata} from 'next'; import {notFound} from 'next/navigation'; import {Locale, NextIntlClientProvider, hasLocale} from 'next-intl'; -import {getMessages, setRequestLocale} from 'next-intl/server'; +import {setRequestLocale} from 'next-intl/server'; import {ReactNode} from 'react'; import Document from '@/components/Document'; import {locales} from '@/config'; @@ -33,13 +33,9 @@ export default async function LocaleLayout({ // Enable static rendering setRequestLocale(locale); - // Providing all messages to the client - // side is the easiest way to get started - const messages = await getMessages(); - return ( - +
{children}
diff --git a/examples/example-app-router-mixed-routing/src/app/app/layout.tsx b/examples/example-app-router-mixed-routing/src/app/app/layout.tsx index cbd693a89..48ece5398 100644 --- a/examples/example-app-router-mixed-routing/src/app/app/layout.tsx +++ b/examples/example-app-router-mixed-routing/src/app/app/layout.tsx @@ -1,6 +1,6 @@ import {Metadata} from 'next'; import {NextIntlClientProvider} from 'next-intl'; -import {getLocale, getMessages} from 'next-intl/server'; +import {getLocale} from 'next-intl/server'; import {ReactNode} from 'react'; import Document from '@/components/Document'; import AppNavigation from './AppNavigation'; @@ -18,13 +18,9 @@ export const metadata: Metadata = { export default async function LocaleLayout({children}: Props) { const locale = await getLocale(); - // Providing all messages to the client - // side is the easiest way to get started - const messages = await getMessages(); - return ( - +
diff --git a/examples/example-app-router-single-locale/src/app/layout.tsx b/examples/example-app-router-single-locale/src/app/layout.tsx index dd542a179..99c0dd7d7 100644 --- a/examples/example-app-router-single-locale/src/app/layout.tsx +++ b/examples/example-app-router-single-locale/src/app/layout.tsx @@ -1,5 +1,5 @@ import {NextIntlClientProvider} from 'next-intl'; -import {getLocale, getMessages} from 'next-intl/server'; +import {getLocale} from 'next-intl/server'; import {ReactNode} from 'react'; type Props = { @@ -9,19 +9,13 @@ type Props = { export default async function LocaleLayout({children}: Props) { const locale = await getLocale(); - // Providing all messages to the client - // side is the easiest way to get started - const messages = await getMessages(); - return ( next-intl - - {children} - + {children} ); diff --git a/examples/example-app-router-without-i18n-routing/src/app/layout.tsx b/examples/example-app-router-without-i18n-routing/src/app/layout.tsx index c9efefe2f..587b115f9 100644 --- a/examples/example-app-router-without-i18n-routing/src/app/layout.tsx +++ b/examples/example-app-router-without-i18n-routing/src/app/layout.tsx @@ -1,7 +1,7 @@ import clsx from 'clsx'; import {Inter} from 'next/font/google'; import {NextIntlClientProvider} from 'next-intl'; -import {getLocale, getMessages} from 'next-intl/server'; +import {getLocale} from 'next-intl/server'; import {ReactNode} from 'react'; import './globals.css'; @@ -14,10 +14,6 @@ type Props = { export default async function LocaleLayout({children}: Props) { const locale = await getLocale(); - // Providing all messages to the client - // side is the easiest way to get started - const messages = await getMessages(); - return ( @@ -29,9 +25,7 @@ export default async function LocaleLayout({children}: Props) { inter.className )} > - - {children} - + {children} ); diff --git a/examples/example-app-router/src/components/BaseLayout.tsx b/examples/example-app-router/src/components/BaseLayout.tsx index 8907d4e9b..89358fbb8 100644 --- a/examples/example-app-router/src/components/BaseLayout.tsx +++ b/examples/example-app-router/src/components/BaseLayout.tsx @@ -1,7 +1,6 @@ import {clsx} from 'clsx'; import {Inter} from 'next/font/google'; import {NextIntlClientProvider} from 'next-intl'; -import {getMessages} from 'next-intl/server'; import {ReactNode} from 'react'; import Navigation from '@/components/Navigation'; @@ -13,14 +12,10 @@ type Props = { }; export default async function BaseLayout({children, locale}: Props) { - // Providing all messages to the client - // side is the easiest way to get started - const messages = await getMessages(); - return ( - + {children} diff --git a/packages/next-intl/.size-limit.ts b/packages/next-intl/.size-limit.ts index e00f8f64a..2eaf0b8a8 100644 --- a/packages/next-intl/.size-limit.ts +++ b/packages/next-intl/.size-limit.ts @@ -10,7 +10,7 @@ const config: SizeLimitConfig = [ name: "import {NextIntlClientProvider} from 'next-intl' (react-client)", import: '{NextIntlClientProvider}', path: 'dist/esm/production/index.react-client.js', - limit: '1 KB' + limit: '1.005 KB' }, { name: "import * from 'next-intl' (react-server)", diff --git a/packages/next-intl/src/react-server/NextIntlClientProviderServer.test.tsx b/packages/next-intl/src/react-server/NextIntlClientProviderServer.test.tsx index 9680df759..2e5cb37e1 100644 --- a/packages/next-intl/src/react-server/NextIntlClientProviderServer.test.tsx +++ b/packages/next-intl/src/react-server/NextIntlClientProviderServer.test.tsx @@ -1,12 +1,13 @@ import {expect, it, vi} from 'vitest'; import getConfigNow from '../server/react-server/getConfigNow.js'; import getFormats from '../server/react-server/getFormats.js'; -import {getLocale, getTimeZone} from '../server.react-server.js'; +import {getLocale, getMessages, getTimeZone} from '../server.react-server.js'; import NextIntlClientProvider from '../shared/NextIntlClientProvider.js'; import NextIntlClientProviderServer from './NextIntlClientProviderServer.js'; vi.mock('../../src/server/react-server', async () => ({ getLocale: vi.fn(async () => 'en-US'), + getMessages: vi.fn(async () => ({})), getTimeZone: vi.fn(async () => 'America/New_York') })); @@ -34,7 +35,8 @@ it("doesn't read from headers if all relevant configuration is passed", async () locale: 'en-GB', now: new Date('2020-02-01T00:00:00.000Z'), timeZone: 'Europe/London', - formats: {} + formats: {}, + messages: {} }); expect(result.type).toBe(NextIntlClientProvider); @@ -43,13 +45,15 @@ it("doesn't read from headers if all relevant configuration is passed", async () locale: 'en-GB', now: new Date('2020-02-01T00:00:00.000Z'), timeZone: 'Europe/London', - formats: {} + formats: {}, + messages: {} }); expect(getLocale).not.toHaveBeenCalled(); expect(getConfigNow).not.toHaveBeenCalled(); expect(getTimeZone).not.toHaveBeenCalled(); expect(getFormats).not.toHaveBeenCalled(); + expect(getMessages).not.toHaveBeenCalled(); }); it('reads missing configuration from getter functions', async () => { @@ -63,6 +67,7 @@ it('reads missing configuration from getter functions', async () => { locale: 'en-US', now: new Date('2020-01-01T00:00:00.000Z'), timeZone: 'America/New_York', + messages: {}, formats: { dateTime: { short: { @@ -76,4 +81,5 @@ it('reads missing configuration from getter functions', async () => { expect(getConfigNow).toHaveBeenCalled(); expect(getTimeZone).toHaveBeenCalled(); expect(getFormats).toHaveBeenCalled(); + expect(getMessages).toHaveBeenCalled(); }); diff --git a/packages/next-intl/src/react-server/NextIntlClientProviderServer.tsx b/packages/next-intl/src/react-server/NextIntlClientProviderServer.tsx index fc03de3d9..f468f1149 100644 --- a/packages/next-intl/src/react-server/NextIntlClientProviderServer.tsx +++ b/packages/next-intl/src/react-server/NextIntlClientProviderServer.tsx @@ -1,7 +1,7 @@ import type {ComponentProps} from 'react'; import getConfigNow from '../server/react-server/getConfigNow.js'; import getFormats from '../server/react-server/getFormats.js'; -import {getLocale, getTimeZone} from '../server.react-server.js'; +import {getLocale, getMessages, getTimeZone} from '../server.react-server.js'; import BaseNextIntlClientProvider from '../shared/NextIntlClientProvider.js'; type Props = ComponentProps; @@ -9,6 +9,7 @@ type Props = ComponentProps; export default async function NextIntlClientProviderServer({ formats, locale, + messages, now, timeZone, ...rest @@ -19,6 +20,7 @@ export default async function NextIntlClientProviderServer({ // See https://github.com/amannn/next-intl/issues/631 formats={formats === undefined ? await getFormats() : formats} locale={locale ?? (await getLocale())} + messages={messages === undefined ? await getMessages() : messages} // Note that we don't assign a default for `now` here, // we only read one from the request config - if any. // Otherwise this would cause a `dynamicIO` error. diff --git a/packages/next-intl/src/server/react-server/getConfig.tsx b/packages/next-intl/src/server/react-server/getConfig.tsx index 8372249a9..8f58b7217 100644 --- a/packages/next-intl/src/server/react-server/getConfig.tsx +++ b/packages/next-intl/src/server/react-server/getConfig.tsx @@ -68,14 +68,16 @@ const receiveRuntimeConfig = cache(receiveRuntimeConfigImpl); const getFormatters = cache(_createIntlFormatters); const getCache = cache(_createCache); -async function getConfigImpl(localeOverride?: Locale): Promise< - IntlConfig & { - getMessageFallback: NonNullable; - onError: NonNullable; - timeZone: NonNullable; - _formatters: ReturnType; - } -> { +async function getConfigImpl(localeOverride?: Locale): Promise<{ + locale: IntlConfig['locale']; + formats?: NonNullable; + timeZone: NonNullable; + onError: NonNullable; + getMessageFallback: NonNullable; + messages?: NonNullable; + now?: NonNullable; + _formatters: ReturnType; +}> { const runtimeConfig = await receiveRuntimeConfig( createRequestConfig, localeOverride diff --git a/packages/next-intl/src/server/react-server/getServerFormatter.tsx b/packages/next-intl/src/server/react-server/getServerFormatter.tsx index 33654204e..d1b6d282f 100644 --- a/packages/next-intl/src/server/react-server/getServerFormatter.tsx +++ b/packages/next-intl/src/server/react-server/getServerFormatter.tsx @@ -3,10 +3,6 @@ import {createFormatter} from 'use-intl/core'; import getDefaultNow from './getDefaultNow.js'; function getFormatterCachedImpl(config: Parameters[0]) { - // same here? - // also add a test - // also for getTranslations/useTranslations - // add a test with a getter maybe, don't mock return createFormatter({ ...config, // Only init when necessary to avoid triggering a `dynamicIO` error diff --git a/packages/use-intl/.size-limit.ts b/packages/use-intl/.size-limit.ts index c6dae5c70..e1bf25098 100644 --- a/packages/use-intl/.size-limit.ts +++ b/packages/use-intl/.size-limit.ts @@ -5,14 +5,14 @@ const config: SizeLimitConfig = [ name: "import * from 'use-intl' (production)", import: '*', path: 'dist/esm/production/index.js', - limit: '12.945 kB' + limit: '12.955 kB' }, { name: "import {IntlProvider, useLocale, useNow, useTimeZone, useMessages, useFormatter} from 'use-intl' (production)", path: 'dist/esm/production/index.js', import: '{IntlProvider, useLocale, useNow, useTimeZone, useMessages, useFormatter}', - limit: '1.98 kB' + limit: '1.995 kB' } ]; diff --git a/packages/use-intl/src/core/IntlConfig.tsx b/packages/use-intl/src/core/IntlConfig.tsx index 20ab1876a..68da27a1e 100644 --- a/packages/use-intl/src/core/IntlConfig.tsx +++ b/packages/use-intl/src/core/IntlConfig.tsx @@ -13,7 +13,7 @@ type IntlConfig = { locale: Locale; /** Global formats can be provided to achieve consistent * formatting across components. */ - formats?: Formats; + formats?: Formats | null; /** A time zone as defined in [the tz database](https://en.wikipedia.org/wiki/List_of_tz_database_time_zones) which will be applied when formatting dates and times. If this is absent, the user time zone will be used. You can override this by supplying an explicit time zone to `formatDateTime`. */ timeZone?: TimeZone; /** This callback will be invoked when an error is encountered during @@ -40,7 +40,7 @@ type IntlConfig = { */ now?: Date; /** All messages that will be available. */ - messages?: DeepPartial; + messages?: DeepPartial | null; }; /** @@ -48,7 +48,12 @@ type IntlConfig = { * A stricter set of the configuration that should be used internally * once defaults are assigned to `IntlConfiguration`. */ -export type InitializedIntlConfig = IntlConfig & { +export type InitializedIntlConfig = Omit< + IntlConfig, + 'formats' | 'messages' | 'onError' | 'getMessageFallback' +> & { + formats?: NonNullable; + messages?: NonNullable; onError: NonNullable; getMessageFallback: NonNullable; }; diff --git a/packages/use-intl/src/core/initializeConfig.tsx b/packages/use-intl/src/core/initializeConfig.tsx index 1bce4c21c..8bace8296 100644 --- a/packages/use-intl/src/core/initializeConfig.tsx +++ b/packages/use-intl/src/core/initializeConfig.tsx @@ -9,7 +9,7 @@ export default function initializeConfig< // This is a generic to allow for stricter typing. E.g. // the RSC integration always provides a `now` value. Props extends IntlConfig ->({getMessageFallback, messages, onError, ...rest}: Props) { +>({formats, getMessageFallback, messages, onError, ...rest}: Props) { const finalOnError = onError || defaultOnError; const finalGetMessageFallback = getMessageFallback || defaultGetMessageFallback; @@ -22,7 +22,12 @@ export default function initializeConfig< return { ...rest, - messages, + formats: (formats || undefined) as + | NonNullable + | undefined, + messages: (messages || undefined) as + | NonNullable + | undefined, onError: finalOnError, getMessageFallback: finalGetMessageFallback }; diff --git a/packages/use-intl/src/react/IntlProvider.test.tsx b/packages/use-intl/src/react/IntlProvider.test.tsx index af2564910..fbe8604a1 100644 --- a/packages/use-intl/src/react/IntlProvider.test.tsx +++ b/packages/use-intl/src/react/IntlProvider.test.tsx @@ -149,3 +149,25 @@ it('does not merge messages in nested providers', () => { expect(onError.mock.calls.length).toBe(1); }); + +it('can opt-out of messages inheritance', () => { + const onError = vi.fn(); + + function Component() { + const t = useTranslations(); + return {t('hello')}; + } + + render( + + + + + + + ); + + screen.getByText('Hey!'); + screen.getByText('hello'); + expect(onError.mock.calls.length).toBe(1); +}); diff --git a/packages/use-intl/src/react/IntlProvider.tsx b/packages/use-intl/src/react/IntlProvider.tsx index 81746ac34..6a67d24c1 100644 --- a/packages/use-intl/src/react/IntlProvider.tsx +++ b/packages/use-intl/src/react/IntlProvider.tsx @@ -49,10 +49,10 @@ export default function IntlProvider({ () => ({ ...initializeConfig({ locale, // (required by provider) - formats: formats || prevContext?.formats, + formats: formats === undefined ? prevContext?.formats : formats, getMessageFallback: getMessageFallback || prevContext?.getMessageFallback, - messages: messages || prevContext?.messages, + messages: messages === undefined ? prevContext?.messages : messages, now: now || prevContext?.now, onError: onError || prevContext?.onError, timeZone: timeZone || prevContext?.timeZone