From eea825b3bb2e46f986b78ff34a327c9d008606f5 Mon Sep 17 00:00:00 2001 From: Jan Amann Date: Wed, 9 Oct 2024 17:13:35 +0200 Subject: [PATCH 1/4] feat: Inherit context --- .../use-intl/src/react/IntlProvider.test.tsx | 83 ++++++++++++++++++- packages/use-intl/src/react/IntlProvider.tsx | 31 ++++--- 2 files changed, 100 insertions(+), 14 deletions(-) diff --git a/packages/use-intl/src/react/IntlProvider.test.tsx b/packages/use-intl/src/react/IntlProvider.test.tsx index 6d785d7e7..68136a817 100644 --- a/packages/use-intl/src/react/IntlProvider.test.tsx +++ b/packages/use-intl/src/react/IntlProvider.test.tsx @@ -1,7 +1,8 @@ import {fireEvent, render, screen} from '@testing-library/react'; import React, {memo, useState} from 'react'; -import {expect, it} from 'vitest'; +import {expect, it, vi} from 'vitest'; import IntlProvider from './IntlProvider'; +import useNow from './useNow'; import useTranslations from './useTranslations'; it("doesn't re-render context consumers unnecessarily", () => { @@ -43,3 +44,83 @@ it("doesn't re-render context consumers unnecessarily", () => { expect(numCounterRenders).toBe(2); expect(numStaticTextRenders).toBe(1); }); + +it('keeps a consistent context value that does not trigger unnecessary re-renders', () => { + const messages = {StaticText: {hello: 'Hello!'}}; + + let numCounterRenders = 0; + function Counter() { + const [count, setCount] = useState(0); + numCounterRenders++; + + return ( + <> + +

Count: {count}

+ + + + + ); + } + + let numStaticTextRenders = 0; + const StaticText = memo(() => { + const t = useTranslations('StaticText'); + numStaticTextRenders++; + return t('hello'); + }); + + render(); + screen.getByText('Count: 0'); + expect(numCounterRenders).toBe(1); + expect(numStaticTextRenders).toBe(1); + fireEvent.click(screen.getByText('Increment')); + screen.getByText('Count: 1'); + expect(numCounterRenders).toBe(2); + expect(numStaticTextRenders).toBe(1); +}); + +it('passes on configuration in nested providers', () => { + const onError = vi.fn(); + + function Component() { + const now = useNow(); + const t = useTranslations(); + t('unknown'); + return t('now', {now}); + } + + render( + + + + + + ); + + screen.getByText('Now: Jan 1, 2021, 1:00 AM'); + 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 4b5820c18..414a2bb3f 100644 --- a/packages/use-intl/src/react/IntlProvider.tsx +++ b/packages/use-intl/src/react/IntlProvider.tsx @@ -1,4 +1,4 @@ -import React, {ReactNode, useMemo} from 'react'; +import React, {ReactNode, useContext, useMemo} from 'react'; import IntlConfig from '../core/IntlConfig'; import { createCache, @@ -23,17 +23,19 @@ export default function IntlProvider({ onError, timeZone }: Props) { + const prevContext = useContext(IntlContext); + // The formatter cache is released when the locale changes. For // long-running apps with a persistent `IntlProvider` at the root, // this can reduce the memory footprint (e.g. in React Native). const cache = useMemo(() => { // eslint-disable-next-line no-unused-expressions locale; - return createCache(); - }, [locale]); + return prevContext?.cache || createCache(); + }, [locale, prevContext?.cache]); const formatters: Formatters = useMemo( - () => createIntlFormatters(cache), - [cache] + () => prevContext?.formatters || createIntlFormatters(cache), + [cache, prevContext?.formatters] ); // Memoizing this value helps to avoid triggering a re-render of all @@ -47,14 +49,16 @@ export default function IntlProvider({ const value = useMemo( () => ({ ...initializeConfig({ - locale, - defaultTranslationValues, - formats, - getMessageFallback, - messages, - now, - onError, - timeZone + locale, // (required by provider) + defaultTranslationValues: + defaultTranslationValues || prevContext?.defaultTranslationValues, + formats: formats || prevContext?.formats, + getMessageFallback: + getMessageFallback || prevContext?.getMessageFallback, + messages: messages || prevContext?.messages, + now: now || prevContext?.now, + onError: onError || prevContext?.onError, + timeZone: timeZone || prevContext?.timeZone }), formatters, cache @@ -69,6 +73,7 @@ export default function IntlProvider({ messages, now, onError, + prevContext, timeZone ] ); From 23cdef2cbf5549232922c476697f011a963aa49b Mon Sep 17 00:00:00 2001 From: Jan Amann Date: Wed, 9 Oct 2024 19:20:04 +0200 Subject: [PATCH 2/4] docs (thankfully a bit simpler now) --- docs/pages/docs/usage/configuration.mdx | 54 +++++++------------ .../use-intl/src/react/IntlProvider.test.tsx | 23 ++++++++ 2 files changed, 41 insertions(+), 36 deletions(-) diff --git a/docs/pages/docs/usage/configuration.mdx b/docs/pages/docs/usage/configuration.mdx index 6d355ec2c..e5c40ff29 100644 --- a/docs/pages/docs/usage/configuration.mdx +++ b/docs/pages/docs/usage/configuration.mdx @@ -99,41 +99,28 @@ In contrast, these props can be provided as necessary: 2. `defaultTranslationValues` 3. `onError` and `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. +
How can I provide non-serializable props like `onError` to `NextIntlClientProvider`? React limits the types of props that can be passed to Client Components to the ones that are [serializable](https://react.dev/reference/rsc/use-client#serializable-types). Since `onError`, `getMessageFallback` and `defaultTranslationValues` can receive functions, these configuration options can't be automatically inherited by the client side. -In order to define these values, you can wrap `NextIntlClientProvider` with another component that is marked with `'use client'` and defines the relevant props: +In order to define these values on the client side, you can add a provider that defines these props: -```tsx filename="IntlProvider.tsx" +```tsx filename="IntlErrorHandlingProvider.tsx" 'use client'; import {NextIntlClientProvider} from 'next-intl'; -export default function IntlProvider({ - locale, - now, - timeZone, - messages, - formats -}) { +export default function IntlErrorHandlingProvider({children}) { return ( {text} - }} onError={(error) => console.error(error)} getMessageFallback={({namespace, key}) => `${namespace}.${key}`} - // Make sure to forward these props to avoid markup mismatches - locale={locale} - now={now} - timeZone={timeZone} - formats={formats} - // Provide as necessary - messages={messages} - /> + > + {children} + ); } ``` @@ -141,27 +128,22 @@ export default function IntlProvider({ Once you have defined your client-side provider component, you can use it in a Server Component: ```tsx filename="layout.tsx" -import IntlProvider from './IntlProvider'; -import {getLocale, getNow, getTimeZone, getMessages} from 'next-intl/server'; -import formats from './formats'; +import {NextIntlClientProvider} from 'next-intl'; +import {getLocale, getMessages} from 'next-intl/server'; +import IntlErrorHandlingProvider from './IntlErrorHandlingProvider'; export default async function RootLayout({children}) { const locale = await getLocale(); - const now = await getNow(); - const timeZone = await getTimeZone(); + + // Providing all messages to the client + // side is the easiest way to get started const messages = await getMessages(); return ( - - {children} + + {children} @@ -171,7 +153,7 @@ export default async function RootLayout({children}) { By doing this, your provider component will already be part of the client-side bundle and can therefore define and pass functions as props. -**Important:** Be sure to pass explicit `locale`, `timeZone` and `now` props to `NextIntlClientProvider` in this case, since these aren't automatically inherited from a Server Component when you import `NextIntlClientProvider` from a Client Component. +Note that the inner `NextIntlClientProvider` inherits the configuration from the outer one, only the `onError` and `getMessageFallback` functions are added.
@@ -592,7 +574,7 @@ export default getRequestConfig(async ({locale}) => { }); ``` -Note that `onError` and `getMessageFallback` are not automatically inherited by Client Components. If you want to make this functionality available in Client Components, you should provide the same configuration to [`NextIntlClientProvider`](#nextintlclientprovider). +Note that `onError` and `getMessageFallback` are not automatically inherited by Client Components. If you want to make this functionality available in Client Components too, you can however create a [client-side provider](#nextintlclientprovider-non-serializable-props) that defines these props. diff --git a/packages/use-intl/src/react/IntlProvider.test.tsx b/packages/use-intl/src/react/IntlProvider.test.tsx index 68136a817..05b9d585d 100644 --- a/packages/use-intl/src/react/IntlProvider.test.tsx +++ b/packages/use-intl/src/react/IntlProvider.test.tsx @@ -124,3 +124,26 @@ it('passes on configuration in nested providers', () => { screen.getByText('Now: Jan 1, 2021, 1:00 AM'); expect(onError.mock.calls.length).toBe(1); }); + +it('does not merge messages in nested providers', () => { + // This is important because the locale can change + // and the messages from a previous locale should + // not leak into the new locale. + + const onError = vi.fn(); + + function Component() { + const t = useTranslations(); + return t('hello'); + } + + render( + + + + + + ); + + expect(onError.mock.calls.length).toBe(1); +}); From 1d996067afd301e4a305ff64b61dfb9b7f717b79 Mon Sep 17 00:00:00 2001 From: Jan Amann Date: Wed, 9 Oct 2024 19:24:52 +0200 Subject: [PATCH 3/4] bump sizes --- packages/next-intl/.size-limit.ts | 8 ++++---- packages/use-intl/.size-limit.ts | 4 ++-- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/next-intl/.size-limit.ts b/packages/next-intl/.size-limit.ts index 82df8e36b..e08a63315 100644 --- a/packages/next-intl/.size-limit.ts +++ b/packages/next-intl/.size-limit.ts @@ -3,11 +3,11 @@ import type {SizeLimitConfig} from 'size-limit'; const config: SizeLimitConfig = [ { path: 'dist/production/index.react-client.js', - limit: '14.095 KB' + limit: '14.125 KB' }, { path: 'dist/production/index.react-server.js', - limit: '14.675 KB' + limit: '14.765 KB' }, { path: 'dist/production/navigation.react-client.js', @@ -36,12 +36,12 @@ const config: SizeLimitConfig = [ { path: 'dist/esm/index.react-client.js', import: '*', - limit: '14.265 kB' + limit: '14.295 kB' }, { path: 'dist/esm/index.react-client.js', import: '{NextIntlClientProvider}', - limit: '1.425 kB' + limit: '1.55 kB' } ]; diff --git a/packages/use-intl/.size-limit.ts b/packages/use-intl/.size-limit.ts index 78561a217..bc777f177 100644 --- a/packages/use-intl/.size-limit.ts +++ b/packages/use-intl/.size-limit.ts @@ -5,14 +5,14 @@ const config: SizeLimitConfig = [ name: './ (ESM)', import: '*', path: 'dist/esm/index.js', - limit: '14.085 kB' + limit: '14.195 kB' }, { name: './ (no useTranslations, ESM)', path: 'dist/esm/index.js', import: '{IntlProvider, useLocale, useNow, useTimeZone, useMessages, useFormatter}', - limit: '2.865 kB' + limit: '2.935 kB' }, { name: './ (CJS)', From a8c6911e47bc86243abe60049220dbeb97ffab57 Mon Sep 17 00:00:00 2001 From: Jan Amann Date: Wed, 9 Oct 2024 19:33:25 +0200 Subject: [PATCH 4/4] Update docs/pages/docs/usage/configuration.mdx --- docs/pages/docs/usage/configuration.mdx | 3 --- 1 file changed, 3 deletions(-) diff --git a/docs/pages/docs/usage/configuration.mdx b/docs/pages/docs/usage/configuration.mdx index e5c40ff29..ff082fc11 100644 --- a/docs/pages/docs/usage/configuration.mdx +++ b/docs/pages/docs/usage/configuration.mdx @@ -134,9 +134,6 @@ import IntlErrorHandlingProvider from './IntlErrorHandlingProvider'; export default async function RootLayout({children}) { const locale = await getLocale(); - - // Providing all messages to the client - // side is the easiest way to get started const messages = await getMessages(); return (