Skip to content

Commit

Permalink
feat!: Don't read a default for useLocale from useParams.locale o…
Browse files Browse the repository at this point in the history
…n 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.
  • Loading branch information
amannn authored Nov 14, 2024
1 parent 2a92f17 commit dc36097
Show file tree
Hide file tree
Showing 18 changed files with 100 additions and 230 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -69,14 +69,17 @@ These functions are available:

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.

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

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

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

// This component will execute as a Server Component by default.
// However, if it is imported from a Client Component, it will
// execute as a Client Component.
return (
<section>
<h2>{t('title')}</h2>
Expand Down
76 changes: 39 additions & 37 deletions docs/src/pages/docs/usage/configuration.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -181,6 +181,17 @@ export default getRequestConfig(async () => {
});
```

<Details id="server-request-locale">
<summary>Which values can the `requestLocale` parameter hold?</summary>

While the `requestLocale` parameter typically corresponds to the `[locale]` segment that was matched by the middleware, there are three special cases to consider:

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.
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`).
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.

</Details>

</Tabs.Tab>
<Tabs.Tab>

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

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

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

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.
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`).
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.
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).
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.

</Details>

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

### `Locale` type [#locale-type]

When passing a `locale` to another function, you can use the `Locale` type for the receiving parameter:

```tsx
import {Locale} from 'next-intl';

async function getPosts(locale: Locale) {
// ...
}
```

<Callout>
By default, `Locale` is typed as `string`. However, you can optionally provide
a strict union based on your supported locales for this type by [augmenting
the `Locale` type](/docs/workflows/typescript#locale).
</Callout>

<Details id="locale-change">
<summary>How can I change the locale?</summary>

Depending on if you're using [i18n routing](/docs/getting-started/app-router), the locale can be changed as follows:

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).
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.

</Details>

<Details id="locale-return-value">
<summary>Which value is returned from `useLocale`?</summary>

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

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`.
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.
1. **Server Components**: The locale represents the value returned in [`i18n/request.ts`](#i18n-request).
2. **Client Components**: The locale is received from [`NextIntlClientProvider`](#nextintlclientprovider).

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.

</Details>

Expand All @@ -277,6 +261,24 @@ return (

</Details>

### `Locale` type [#locale-type]

When passing a `locale` to another function, you can use the `Locale` type for the receiving parameter:

```tsx
import {Locale} from 'next-intl';

async function getPosts(locale: Locale) {
// ...
}
```

<Callout>
By default, `Locale` is typed as `string`. However, you can optionally provide
a strict union based on your supported locales for this type by [augmenting
the `Locale` type](/docs/workflows/typescript#locale).
</Callout>

## Messages

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.
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import {Metadata} from 'next';
import {notFound} from 'next/navigation';
import {Locale, hasLocale} from 'next-intl';
import {Locale, NextIntlClientProvider, hasLocale} from 'next-intl';
import {
getFormatter,
getNow,
Expand Down Expand Up @@ -50,8 +50,10 @@ export default function LocaleLayout({children, params: {locale}}: Props) {
lineHeight: 1.5
}}
>
<Navigation />
{children}
<NextIntlClientProvider>
<Navigation />
{children}
</NextIntlClientProvider>
</div>
</body>
</html>
Expand Down
17 changes: 16 additions & 1 deletion packages/next-intl/eslint.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,21 @@ export default (await getPresets('typescript', 'react', 'vitest')).concat({
'react-compiler': reactCompilerPlugin
},
rules: {
'react-compiler/react-compiler': 'error'
'react-compiler/react-compiler': 'error',
'no-restricted-imports': [
'error',
{
paths: [
{
// Because:
// - Avoid hardcoding the `locale` param
// - Prepare for a new API in Next.js to read params deeply
// - Avoid issues with `dynamicIO`
name: 'next/navigation.js',
importNames: ['useParams']
}
]
}
]
}
});
28 changes: 12 additions & 16 deletions packages/next-intl/src/navigation/createNavigation.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,29 +2,27 @@ import {render, screen} from '@testing-library/react';
import {
RedirectType,
permanentRedirect as nextPermanentRedirect,
redirect as nextRedirect,
useParams as nextUseParams
redirect as nextRedirect
} from 'next/navigation.js';
import {renderToString} from 'react-dom/server';
import {Locale} from 'use-intl';
import {Locale, useLocale} from 'use-intl';
import {beforeEach, describe, expect, it, vi} from 'vitest';
import {useLocale} from '../index.react-server.tsx';
import {DomainsConfig, Pathnames, defineRouting} from '../routing.tsx';
import createNavigationClient from './react-client/createNavigation.tsx';
import createNavigationServer from './react-server/createNavigation.tsx';
import getServerLocale from './react-server/getServerLocale.tsx';

vi.mock('react');
vi.mock('next/navigation.js', async () => {
const actual = await vi.importActual('next/navigation.js');
return {
...actual,
useParams: vi.fn(() => ({locale: 'en'})),
redirect: vi.fn(),
permanentRedirect: vi.fn()
};
});
vi.mock('next/navigation.js', async () => ({
...(await vi.importActual('next/navigation.js')),
redirect: vi.fn(),
permanentRedirect: vi.fn()
}));
vi.mock('./react-server/getServerLocale');
vi.mock('use-intl', async () => ({
...(await vi.importActual('use-intl')),
useLocale: vi.fn(() => 'en')
}));

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

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

vi.mocked(nextUseParams<{locale: Locale}>).mockImplementation(() => ({
locale
}));
vi.mocked(useLocale).mockImplementation(() => locale);
}

function mockLocation(location: Partial<typeof window.location>) {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,21 +1,22 @@
import {fireEvent, render, screen} from '@testing-library/react';
import {
usePathname as useNextPathname,
useRouter as useNextRouter,
useParams
useRouter as useNextRouter
} from 'next/navigation.js';
import type {Locale} from 'use-intl';
import {useLocale} from 'use-intl';
import {beforeEach, describe, expect, it, vi} from 'vitest';
import {NextIntlClientProvider, useLocale} from '../../index.react-client.tsx';
import {DomainsConfig, Pathnames} from '../../routing.tsx';
import createNavigation from './createNavigation.tsx';

vi.mock('next/navigation.js');
vi.mock('use-intl', async () => ({
...(await vi.importActual('use-intl')),
useLocale: vi.fn(() => 'en')
}));

function mockCurrentLocale(locale: Locale) {
vi.mocked(useParams<{locale: Locale}>).mockImplementation(() => ({
locale
}));
vi.mocked(useLocale).mockImplementation(() => locale);
}

function mockLocation(
Expand Down Expand Up @@ -112,29 +113,6 @@ describe("localePrefix: 'always'", () => {
});

describe('Link', () => {
describe('usage outside of Next.js', () => {
beforeEach(() => {
vi.mocked(useParams<any>).mockImplementation((() => null) as any);
});

it('works with a provider', () => {
render(
<NextIntlClientProvider locale="en">
<Link href="/test">Test</Link>
</NextIntlClientProvider>
);
expect(
screen.getByRole('link', {name: 'Test'}).getAttribute('href')
).toBe('/en/test');
});

it('throws without a provider', () => {
expect(() => render(<Link href="/test">Test</Link>)).toThrow(
'No intl context found. Have you configured the provider?'
);
});
});

it('can receive a ref', () => {
let ref;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,7 @@ import {
useRouter as useNextRouter
} from 'next/navigation.js';
import {useMemo} from 'react';
import type {Locale} from 'use-intl';
import useLocale from '../../react-client/useLocale.tsx';
import {type Locale, useLocale} from 'use-intl';
import {
RoutingConfigLocalizedNavigation,
RoutingConfigSharedNavigation
Expand Down
Original file line number Diff line number Diff line change
@@ -1,14 +1,18 @@
import {render, screen} from '@testing-library/react';
import {usePathname as useNextPathname, useParams} from 'next/navigation.js';
import {usePathname as useNextPathname} from 'next/navigation.js';
import {beforeEach, describe, expect, it, vi} from 'vitest';
import {NextIntlClientProvider} from '../../index.react-client.tsx';
import {NextIntlClientProvider, useLocale} from '../../index.react-client.tsx';
import useBasePathname from './useBasePathname.tsx';

vi.mock('next/navigation.js');
vi.mock('use-intl', async () => ({
...(await vi.importActual('use-intl')),
useLocale: vi.fn(() => 'en')
}));

function mockPathname(pathname: string) {
vi.mocked(useNextPathname).mockImplementation(() => pathname);
vi.mocked(useParams<any>).mockImplementation(() => ({locale: 'en'}));
vi.mocked(useLocale).mockImplementation(() => 'en');
}

function Component() {
Expand Down Expand Up @@ -51,7 +55,6 @@ describe('prefixed routing', () => {
describe('usage outside of Next.js', () => {
beforeEach(() => {
vi.mocked(useNextPathname).mockImplementation((() => null) as any);
vi.mocked(useParams<any>).mockImplementation((() => null) as any);
});

it('returns `null` when used within a provider', () => {
Expand All @@ -62,10 +65,4 @@ describe('usage outside of Next.js', () => {
);
expect(container.innerHTML).toBe('');
});

it('throws without a provider', () => {
expect(() => render(<Component />)).toThrow(
'No intl context found. Have you configured the provider?'
);
});
});
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import {usePathname as useNextPathname} from 'next/navigation.js';
import {useMemo} from 'react';
import useLocale from '../../react-client/useLocale.tsx';
import {useLocale} from 'use-intl';
import {
LocalePrefixConfigVerbose,
LocalePrefixMode,
Expand Down
3 changes: 1 addition & 2 deletions packages/next-intl/src/navigation/shared/BaseLink.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,7 @@ import {
useEffect,
useState
} from 'react';
import type {Locale} from 'use-intl';
import useLocale from '../../react-client/useLocale.tsx';
import {type Locale, useLocale} from 'use-intl';
import {InitializedLocaleCookieConfig} from '../../routing/config.tsx';
import syncLocaleCookie from './syncLocaleCookie.tsx';

Expand Down
3 changes: 0 additions & 3 deletions packages/next-intl/src/react-client/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,4 @@ export const useFormatter = callHook(
base_useFormatter
) as typeof base_useFormatter;

// Replace `useLocale` export from `use-intl`
export {default as useLocale} from './useLocale.tsx';

export {default as NextIntlClientProvider} from '../shared/NextIntlClientProvider.tsx';
33 changes: 0 additions & 33 deletions packages/next-intl/src/react-client/useLocale.test.tsx

This file was deleted.

Loading

0 comments on commit dc36097

Please sign in to comment.