Skip to content

Commit

Permalink
feat!: Inherit context between providers (#1413)
Browse files Browse the repository at this point in the history
If you have nested providers, previously only the configuration of the
innermost one would be applied.

With this change, configuration is now passed from one provider to the
next, while allowing to override individual props.

**BREAKING CHANGE:** There's a very rare chance that this change affects
your app, but in case you've previously relied on providers not
inheriting from each other, you now have to reset props manually in case
you want to retain the prev. behavior.
  • Loading branch information
amannn authored Oct 9, 2024
1 parent 8b4c7c4 commit d80d691
Show file tree
Hide file tree
Showing 5 changed files with 144 additions and 56 deletions.
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
}),
formatters,
cache
Expand All @@ -69,6 +73,7 @@ export default function IntlProvider({
messages,
now,
onError,
prevContext,
timeZone
]
);
Expand Down

0 comments on commit d80d691

Please sign in to comment.