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

feat!: Inherit context in case nested NextIntlClientProvider instances are present #1413

Merged
merged 4 commits into from
Oct 9, 2024
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
51 changes: 15 additions & 36 deletions docs/pages/docs/usage/configuration.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -99,69 +99,48 @@ 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.

<Details id="nextintlclientprovider-non-serializable-props">
<summary>How can I provide non-serializable props like `onError` to `NextIntlClientProvider`?</summary>

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 (
<NextIntlClientProvider
// Define non-serializable props here
defaultTranslationValues={{
i: (text) => <i>{text}</i>
}}
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}
</NextIntlClientProvider>
);
}
```

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();
const messages = await getMessages();

return (
<html lang={locale}>
<body>
<NextIntlClientProvider
locale={locale}
now={now}
timeZone={timeZone}
messages={messages}
formats={formats}
>
{children}
<NextIntlClientProvider messages={messages}>
<IntlErrorHandlingProvider>{children}</IntlErrorHandlingProvider>
</NextIntlClientProvider>
</body>
</html>
Expand All @@ -171,7 +150,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.

</Details>

Expand Down Expand Up @@ -592,7 +571,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.

</Tab>
<Tab>
Expand Down
8 changes: 4 additions & 4 deletions packages/next-intl/.size-limit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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'
}
];

Expand Down
4 changes: 2 additions & 2 deletions packages/use-intl/.size-limit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)',
Expand Down
106 changes: 105 additions & 1 deletion packages/use-intl/src/react/IntlProvider.test.tsx
Original file line number Diff line number Diff line change
@@ -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", () => {
Expand Down Expand Up @@ -43,3 +44,106 @@ 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 (
<>
<button onClick={() => setCount(count + 1)} type="button">
Increment
</button>
<p>Count: {count}</p>
<IntlProvider locale="en" messages={messages}>
<StaticText />
</IntlProvider>
</>
);
}

let numStaticTextRenders = 0;
const StaticText = memo(() => {
const t = useTranslations('StaticText');
numStaticTextRenders++;
return t('hello');
});

render(<Counter />);
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(
<IntlProvider
formats={{
dateTime: {
short: {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: 'numeric',
minute: 'numeric'
}
}
}}
locale="en"
messages={{now: 'Now: {now, date, short}'}}
now={new Date('2021-01-01T00:00:00Z')}
// (timeZone is undefined)
>
<IntlProvider
locale="en" // Ideally wouldn't have to specify, but not too bad
onError={onError}
timeZone="Europe/Vienna"
>
<Component />
</IntlProvider>
</IntlProvider>
);

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(
<IntlProvider locale="en" messages={{hello: 'Hello!'}} onError={onError}>
<IntlProvider locale="de" messages={{bye: 'Tschüss!'}}>
<Component />
</IntlProvider>
</IntlProvider>
);

expect(onError.mock.calls.length).toBe(1);
});
31 changes: 18 additions & 13 deletions packages/use-intl/src/react/IntlProvider.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import React, {ReactNode, useMemo} from 'react';
import React, {ReactNode, useContext, useMemo} from 'react';
import IntlConfig from '../core/IntlConfig';
import {
createCache,
Expand All @@ -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
Expand 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
}),
Copy link
Owner Author

Choose a reason for hiding this comment

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

Doesn't minify terribly well, but can't really think of a better solution …

formatters,
cache
Expand All @@ -69,6 +73,7 @@ export default function IntlProvider({
messages,
now,
onError,
prevContext,
timeZone
]
);
Expand Down
Loading