Skip to content

Commit dc36097

Browse files
authored
feat!: Don't read a default for useLocale from useParams.locale on the client side, but rely on NextIntlClientProvider being used (preparation for dynamicIO) (#1541)
Previously, `useParams.locale` was consulted when reading from `useLocale()` on the client side, allowing to use this API even when no `NextIntlClientProvider` is used. This behavior has now been removed because: 1. Reading from `useParams().locale` doesn't apply if you're using an [App Router setup](https://next-intl-docs.vercel.app/docs/getting-started/app-router) without i18n routing. 2. Reading from `useParams()` might require additional work from the developer in the future to work with the upcoming [`dynamicIO`](https://nextjs.org/docs/canary/app/api-reference/config/next-config-js/dynamicIO) rendering mode like adding `'use cache'` or a `<Suspense />` boundary. Therefore, if you use any features from `next-intl` on the client side, you should now add a `NextIntlClientProvider` in the root layout and wrap all relevant components: ```tsx import {NextIntlClientProvider} from 'next-intl'; export default async function LocaleLayout(/* ... */) { // ... return ( <html lang={locale}> <body> <NextIntlClientProvider> {children} </NextIntlClientProvider> </body> </html> ); } ``` Note that also navigation APIs like `Link` rely on `useLocale` internally.
1 parent 2a92f17 commit dc36097

File tree

18 files changed

+100
-230
lines changed

18 files changed

+100
-230
lines changed

Diff for: docs/src/pages/docs/environments/server-client-components.mdx

+4-1
Original file line numberDiff line numberDiff line change
@@ -69,14 +69,17 @@ These functions are available:
6969

7070
Components that aren't declared with the `async` keyword and don't use interactive features like `useState`, are referred to as [shared components](https://github.com/reactjs/rfcs/blob/main/text/0188-server-components.md#sharing-code-between-server-and-client). These can render either as a Server or Client Component, depending on where they are imported from.
7171

72-
In Next.js, Server Components are the default, and therefore shared components will typically execute as Server Components.
72+
In Next.js, Server Components are the default, and therefore shared components will typically execute as Server Components:
7373

7474
```tsx filename="UserDetails.tsx"
7575
import {useTranslations} from 'next-intl';
7676

7777
export default function UserDetails({user}) {
7878
const t = useTranslations('UserProfile');
7979

80+
// This component will execute as a Server Component by default.
81+
// However, if it is imported from a Client Component, it will
82+
// execute as a Client Component.
8083
return (
8184
<section>
8285
<h2>{t('title')}</h2>

Diff for: docs/src/pages/docs/usage/configuration.mdx

+39-37
Original file line numberDiff line numberDiff line change
@@ -181,6 +181,17 @@ export default getRequestConfig(async () => {
181181
});
182182
```
183183

184+
<Details id="server-request-locale">
185+
<summary>Which values can the `requestLocale` parameter hold?</summary>
186+
187+
While the `requestLocale` parameter typically corresponds to the `[locale]` segment that was matched by the middleware, there are three special cases to consider:
188+
189+
1. **Overrides**: When an explicit `locale` is passed to [awaitable functions](/docs/environments/actions-metadata-route-handlers) like `getTranslations({locale: 'en'})`, then this value will be used instead of the segment.
190+
1. **`undefined`**: The value can be `undefined` when a page outside of the `[locale]` segment renders (e.g. a language selection page at `app/page.tsx`).
191+
1. **Invalid values**: Since the `[locale]` segment effectively acts like a catch-all for unknown routes (e.g. `/unknown.txt`), invalid values should be replaced with a valid locale. In addition to this, you might want to call `notFound()` in [the root layout](/docs/getting-started/app-router/with-i18n-routing#layout) to abort the render in this case.
192+
193+
</Details>
194+
184195
</Tabs.Tab>
185196
<Tabs.Tab>
186197

@@ -191,14 +202,13 @@ export default getRequestConfig(async () => {
191202
</Tabs.Tab>
192203
</Tabs>
193204

194-
<Details id="server-request-locale">
195-
<summary>Which values can the `requestLocale` parameter hold?</summary>
205+
<Details id="locale-change">
206+
<summary>How can I change the locale?</summary>
196207

197-
While the `requestLocale` parameter typically corresponds to the `[locale]` segment that was matched by the middleware, there are three special cases to consider:
208+
Depending on if you're using [i18n routing](/docs/getting-started/app-router), the locale can be changed as follows:
198209

199-
1. **Overrides**: When an explicit `locale` is passed to [awaitable functions](/docs/environments/actions-metadata-route-handlers) like `getTranslations({locale: 'en'})`, then this value will be used instead of the segment.
200-
1. **`undefined`**: The value can be `undefined` when a page outside of the `[locale]` segment renders (e.g. a language selection page at `app/page.tsx`).
201-
1. **Invalid values**: Since the `[locale]` segment effectively acts like a catch-all for unknown routes (e.g. `/unknown.txt`), invalid values should be replaced with a valid locale. In addition to this, you might want to call `notFound()` in [the root layout](/docs/getting-started/app-router/with-i18n-routing#layout) to abort the render in this case.
210+
1. **With i18n routing**: The locale is managed by the router and can be changed by using navigation APIs from `next-intl` like [`Link`](/docs/routing/navigation#link) or [`useRouter`](/docs/routing/navigation#userouter).
211+
2. **Without i18n routing**: You can change the locale by updating the value where the locale is read from (e.g. a cookie, a user setting, etc.). If you're looking for inspiration, you can have a look at the [App Router without i18n routing example](/examples#app-router-without-i18n-routing) that manages the locale via a cookie.
202212

203213
</Details>
204214

@@ -218,41 +228,15 @@ import {getLocale} from 'next-intl/server';
218228
const locale = await getLocale();
219229
```
220230

221-
### `Locale` type [#locale-type]
222-
223-
When passing a `locale` to another function, you can use the `Locale` type for the receiving parameter:
224-
225-
```tsx
226-
import {Locale} from 'next-intl';
227-
228-
async function getPosts(locale: Locale) {
229-
// ...
230-
}
231-
```
232-
233-
<Callout>
234-
By default, `Locale` is typed as `string`. However, you can optionally provide
235-
a strict union based on your supported locales for this type by [augmenting
236-
the `Locale` type](/docs/workflows/typescript#locale).
237-
</Callout>
238-
239-
<Details id="locale-change">
240-
<summary>How can I change the locale?</summary>
241-
242-
Depending on if you're using [i18n routing](/docs/getting-started/app-router), the locale can be changed as follows:
243-
244-
1. **With i18n routing**: The locale is managed by the router and can be changed by using navigation APIs from `next-intl` like [`Link`](/docs/routing/navigation#link) or [`useRouter`](/docs/routing/navigation#userouter).
245-
2. **Without i18n routing**: You can change the locale by updating the value where the locale is read from (e.g. a cookie, a user setting, etc.). If you're looking for inspiration, you can have a look at the [App Router without i18n routing example](/examples#app-router-without-i18n-routing) that manages the locale via a cookie.
246-
247-
</Details>
248-
249231
<Details id="locale-return-value">
250232
<summary>Which value is returned from `useLocale`?</summary>
251233

252-
The returned value is resolved based on these priorities:
234+
Depending on how a component renders, the returned locale corresponds to:
253235

254-
1. **Server Components**: If you're using [i18n routing](/docs/getting-started/app-router), the returned locale is the one that you've either provided via [`setRequestLocale`](/docs/getting-started/app-router/with-i18n-routing#static-rendering) or alternatively the one in the `[locale]` segment that was matched by the middleware. If you're not using i18n routing, the returned locale is the one that you've provided via `getRequestConfig`.
255-
2. **Client Components**: In this case, the locale is received from `NextIntlClientProvider` or alternatively `useParams().locale`. Note that `NextIntlClientProvider` automatically inherits the locale if the component is rendered by a Server Component.
236+
1. **Server Components**: The locale represents the value returned in [`i18n/request.ts`](#i18n-request).
237+
2. **Client Components**: The locale is received from [`NextIntlClientProvider`](#nextintlclientprovider).
238+
239+
Note that `NextIntlClientProvider` automatically inherits the locale if it is rendered by a Server Component, therefore you rarely need to pass a locale to `NextIntlClientProvider` yourself.
256240

257241
</Details>
258242

@@ -277,6 +261,24 @@ return (
277261

278262
</Details>
279263

264+
### `Locale` type [#locale-type]
265+
266+
When passing a `locale` to another function, you can use the `Locale` type for the receiving parameter:
267+
268+
```tsx
269+
import {Locale} from 'next-intl';
270+
271+
async function getPosts(locale: Locale) {
272+
// ...
273+
}
274+
```
275+
276+
<Callout>
277+
By default, `Locale` is typed as `string`. However, you can optionally provide
278+
a strict union based on your supported locales for this type by [augmenting
279+
the `Locale` type](/docs/workflows/typescript#locale).
280+
</Callout>
281+
280282
## Messages
281283

282284
The most crucial aspect of internationalization is providing labels based on the user's language. The recommended workflow is to store your messages in your repository along with the code.

Diff for: examples/example-app-router-playground/src/app/[locale]/layout.tsx

+5-3
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import {Metadata} from 'next';
22
import {notFound} from 'next/navigation';
3-
import {Locale, hasLocale} from 'next-intl';
3+
import {Locale, NextIntlClientProvider, hasLocale} from 'next-intl';
44
import {
55
getFormatter,
66
getNow,
@@ -50,8 +50,10 @@ export default function LocaleLayout({children, params: {locale}}: Props) {
5050
lineHeight: 1.5
5151
}}
5252
>
53-
<Navigation />
54-
{children}
53+
<NextIntlClientProvider>
54+
<Navigation />
55+
{children}
56+
</NextIntlClientProvider>
5557
</div>
5658
</body>
5759
</html>

Diff for: packages/next-intl/eslint.config.mjs

+16-1
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,21 @@ export default (await getPresets('typescript', 'react', 'vitest')).concat({
66
'react-compiler': reactCompilerPlugin
77
},
88
rules: {
9-
'react-compiler/react-compiler': 'error'
9+
'react-compiler/react-compiler': 'error',
10+
'no-restricted-imports': [
11+
'error',
12+
{
13+
paths: [
14+
{
15+
// Because:
16+
// - Avoid hardcoding the `locale` param
17+
// - Prepare for a new API in Next.js to read params deeply
18+
// - Avoid issues with `dynamicIO`
19+
name: 'next/navigation.js',
20+
importNames: ['useParams']
21+
}
22+
]
23+
}
24+
]
1025
}
1126
});

Diff for: packages/next-intl/src/navigation/createNavigation.test.tsx

+12-16
Original file line numberDiff line numberDiff line change
@@ -2,29 +2,27 @@ import {render, screen} from '@testing-library/react';
22
import {
33
RedirectType,
44
permanentRedirect as nextPermanentRedirect,
5-
redirect as nextRedirect,
6-
useParams as nextUseParams
5+
redirect as nextRedirect
76
} from 'next/navigation.js';
87
import {renderToString} from 'react-dom/server';
9-
import {Locale} from 'use-intl';
8+
import {Locale, useLocale} from 'use-intl';
109
import {beforeEach, describe, expect, it, vi} from 'vitest';
11-
import {useLocale} from '../index.react-server.tsx';
1210
import {DomainsConfig, Pathnames, defineRouting} from '../routing.tsx';
1311
import createNavigationClient from './react-client/createNavigation.tsx';
1412
import createNavigationServer from './react-server/createNavigation.tsx';
1513
import getServerLocale from './react-server/getServerLocale.tsx';
1614

1715
vi.mock('react');
18-
vi.mock('next/navigation.js', async () => {
19-
const actual = await vi.importActual('next/navigation.js');
20-
return {
21-
...actual,
22-
useParams: vi.fn(() => ({locale: 'en'})),
23-
redirect: vi.fn(),
24-
permanentRedirect: vi.fn()
25-
};
26-
});
16+
vi.mock('next/navigation.js', async () => ({
17+
...(await vi.importActual('next/navigation.js')),
18+
redirect: vi.fn(),
19+
permanentRedirect: vi.fn()
20+
}));
2721
vi.mock('./react-server/getServerLocale');
22+
vi.mock('use-intl', async () => ({
23+
...(await vi.importActual('use-intl')),
24+
useLocale: vi.fn(() => 'en')
25+
}));
2826

2927
function mockCurrentLocale(locale: Locale) {
3028
// Enable synchronous rendering without having to suspend
@@ -35,9 +33,7 @@ function mockCurrentLocale(locale: Locale) {
3533

3634
vi.mocked(getServerLocale).mockImplementation(() => promise);
3735

38-
vi.mocked(nextUseParams<{locale: Locale}>).mockImplementation(() => ({
39-
locale
40-
}));
36+
vi.mocked(useLocale).mockImplementation(() => locale);
4137
}
4238

4339
function mockLocation(location: Partial<typeof window.location>) {

Diff for: packages/next-intl/src/navigation/react-client/createNavigation.test.tsx

+7-29
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,22 @@
11
import {fireEvent, render, screen} from '@testing-library/react';
22
import {
33
usePathname as useNextPathname,
4-
useRouter as useNextRouter,
5-
useParams
4+
useRouter as useNextRouter
65
} from 'next/navigation.js';
76
import type {Locale} from 'use-intl';
7+
import {useLocale} from 'use-intl';
88
import {beforeEach, describe, expect, it, vi} from 'vitest';
9-
import {NextIntlClientProvider, useLocale} from '../../index.react-client.tsx';
109
import {DomainsConfig, Pathnames} from '../../routing.tsx';
1110
import createNavigation from './createNavigation.tsx';
1211

1312
vi.mock('next/navigation.js');
13+
vi.mock('use-intl', async () => ({
14+
...(await vi.importActual('use-intl')),
15+
useLocale: vi.fn(() => 'en')
16+
}));
1417

1518
function mockCurrentLocale(locale: Locale) {
16-
vi.mocked(useParams<{locale: Locale}>).mockImplementation(() => ({
17-
locale
18-
}));
19+
vi.mocked(useLocale).mockImplementation(() => locale);
1920
}
2021

2122
function mockLocation(
@@ -112,29 +113,6 @@ describe("localePrefix: 'always'", () => {
112113
});
113114

114115
describe('Link', () => {
115-
describe('usage outside of Next.js', () => {
116-
beforeEach(() => {
117-
vi.mocked(useParams<any>).mockImplementation((() => null) as any);
118-
});
119-
120-
it('works with a provider', () => {
121-
render(
122-
<NextIntlClientProvider locale="en">
123-
<Link href="/test">Test</Link>
124-
</NextIntlClientProvider>
125-
);
126-
expect(
127-
screen.getByRole('link', {name: 'Test'}).getAttribute('href')
128-
).toBe('/en/test');
129-
});
130-
131-
it('throws without a provider', () => {
132-
expect(() => render(<Link href="/test">Test</Link>)).toThrow(
133-
'No intl context found. Have you configured the provider?'
134-
);
135-
});
136-
});
137-
138116
it('can receive a ref', () => {
139117
let ref;
140118

Diff for: packages/next-intl/src/navigation/react-client/createNavigation.tsx

+1-2
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,7 @@ import {
33
useRouter as useNextRouter
44
} from 'next/navigation.js';
55
import {useMemo} from 'react';
6-
import type {Locale} from 'use-intl';
7-
import useLocale from '../../react-client/useLocale.tsx';
6+
import {type Locale, useLocale} from 'use-intl';
87
import {
98
RoutingConfigLocalizedNavigation,
109
RoutingConfigSharedNavigation

Diff for: packages/next-intl/src/navigation/react-client/useBasePathname.test.tsx

+7-10
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,18 @@
11
import {render, screen} from '@testing-library/react';
2-
import {usePathname as useNextPathname, useParams} from 'next/navigation.js';
2+
import {usePathname as useNextPathname} from 'next/navigation.js';
33
import {beforeEach, describe, expect, it, vi} from 'vitest';
4-
import {NextIntlClientProvider} from '../../index.react-client.tsx';
4+
import {NextIntlClientProvider, useLocale} from '../../index.react-client.tsx';
55
import useBasePathname from './useBasePathname.tsx';
66

77
vi.mock('next/navigation.js');
8+
vi.mock('use-intl', async () => ({
9+
...(await vi.importActual('use-intl')),
10+
useLocale: vi.fn(() => 'en')
11+
}));
812

913
function mockPathname(pathname: string) {
1014
vi.mocked(useNextPathname).mockImplementation(() => pathname);
11-
vi.mocked(useParams<any>).mockImplementation(() => ({locale: 'en'}));
15+
vi.mocked(useLocale).mockImplementation(() => 'en');
1216
}
1317

1418
function Component() {
@@ -51,7 +55,6 @@ describe('prefixed routing', () => {
5155
describe('usage outside of Next.js', () => {
5256
beforeEach(() => {
5357
vi.mocked(useNextPathname).mockImplementation((() => null) as any);
54-
vi.mocked(useParams<any>).mockImplementation((() => null) as any);
5558
});
5659

5760
it('returns `null` when used within a provider', () => {
@@ -62,10 +65,4 @@ describe('usage outside of Next.js', () => {
6265
);
6366
expect(container.innerHTML).toBe('');
6467
});
65-
66-
it('throws without a provider', () => {
67-
expect(() => render(<Component />)).toThrow(
68-
'No intl context found. Have you configured the provider?'
69-
);
70-
});
7168
});

Diff for: packages/next-intl/src/navigation/react-client/useBasePathname.tsx

+1-1
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import {usePathname as useNextPathname} from 'next/navigation.js';
22
import {useMemo} from 'react';
3-
import useLocale from '../../react-client/useLocale.tsx';
3+
import {useLocale} from 'use-intl';
44
import {
55
LocalePrefixConfigVerbose,
66
LocalePrefixMode,

Diff for: packages/next-intl/src/navigation/shared/BaseLink.tsx

+1-2
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,7 @@ import {
1010
useEffect,
1111
useState
1212
} from 'react';
13-
import type {Locale} from 'use-intl';
14-
import useLocale from '../../react-client/useLocale.tsx';
13+
import {type Locale, useLocale} from 'use-intl';
1514
import {InitializedLocaleCookieConfig} from '../../routing/config.tsx';
1615
import syncLocaleCookie from './syncLocaleCookie.tsx';
1716

Diff for: packages/next-intl/src/react-client/index.tsx

-3
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,4 @@ export const useFormatter = callHook(
4646
base_useFormatter
4747
) as typeof base_useFormatter;
4848

49-
// Replace `useLocale` export from `use-intl`
50-
export {default as useLocale} from './useLocale.tsx';
51-
5249
export {default as NextIntlClientProvider} from '../shared/NextIntlClientProvider.tsx';

Diff for: packages/next-intl/src/react-client/useLocale.test.tsx

-33
This file was deleted.

0 commit comments

Comments
 (0)