Skip to content

Commit

Permalink
feat: Add async requestLocale param to getRequestConfig for Next.…
Browse files Browse the repository at this point in the history
…js 15 support (amannn#1383)

Since [Next.js is switching `headers()` to be
`async`](vercel/next.js#68812), the `locale`
that is passed to `getRequestConfig` needs to be replaced by an
awaitable alternative. Note that this is only relevant for your app in
case you're using i18n routing.

## tldr;

Switch to the new API and call it a day:

```diff
export default getRequestConfig(async ({
-  locale
+  requestLocale
}) => {
+  const locale = await requestLocale;

  // ...
});
```

If your app worked well before, then this is a 1:1 switch and will get
your app in shape for Next.js 15.

## Details

The new `requestLocale` parameter also offered a chance to get in some
enhancements for edge cases that were previously harder to support.
Therefore, the following migration is generally recommended:

**Before:**

```tsx
import {notFound} from 'next/navigation';
import {getRequestConfig} from 'next-intl/server';
import {routing} from './routing';
 
export default getRequestConfig(async ({locale}) => {
  // Validate that the incoming `locale` parameter is valid
  if (!routing.locales.includes(locale as any)) notFound();
 
  return {
    // ...
  };
});
```

**After:**

```tsx filename="src/i18n/request.ts"
import {getRequestConfig} from 'next-intl/server';
import {routing} from './routing';

export default getRequestConfig(async ({requestLocale}) => {
  // This typically corresponds to the `[locale]` segment
  let locale = await requestLocale;

  // Ensure that the incoming locale is valid
  if (!locale || !routing.locales.includes(locale as any)) {
    locale = routing.defaultLocale;
  }

  return {
    locale,
    // ...
  };
});
```

The differences are:
1. `requestLocale` is a promise that needs to be awaited
2. The resolved value can be `undefined`—therefore a default should be
supplied. The default assignment allows handling cases where an error
would be thrown previously, e.g. when using APIs like `useTranslations`
on a global language selection page at `app/page.tsx`.
3. The `locale` should be returned (since you can now adjust it in the
function body).
4. We now recommend calling `notFound()` in response to an invalid
`[locale]` param in
[`app/[locale]/layout.tsx`](https://next-intl-docs-git-feat-async-request-locale-next-intl.vercel.app/docs/getting-started/app-router/with-i18n-routing#layout)
instead of in `i18n/request.ts`. This unlocks another use case, where
APIs like `useTranslations` can now be used on a global
`app/not-found.tsx` page.

See also the [updated getting started
docs](https://next-intl-docs-git-feat-async-request-locale-next-intl.vercel.app/docs/getting-started/app-router/with-i18n-routing#i18n-request).

Note that this change is non-breaking, but the synchronously available
`locale` is now considered deprecated and will be removed in a future
major version.

Contributes to amannn#1375
Addresses amannn#1355
  • Loading branch information
amannn authored Oct 2, 2024
1 parent 2d93d65 commit 29394c0
Showing 15 changed files with 157 additions and 73 deletions.
10 changes: 5 additions & 5 deletions .size-limit.ts
Original file line number Diff line number Diff line change
@@ -9,7 +9,7 @@ const config: SizeLimitConfig = [
{
name: 'import * from \'next-intl\' (react-server)',
path: 'dist/production/index.react-server.js',
limit: '14.665 KB'
limit: '14.725 KB'
},
{
name: 'import {createSharedPathnamesNavigation} from \'next-intl/navigation\' (react-client)',
@@ -33,19 +33,19 @@ const config: SizeLimitConfig = [
name: 'import {createSharedPathnamesNavigation} from \'next-intl/navigation\' (react-server)',
path: 'dist/production/navigation.react-server.js',
import: '{createSharedPathnamesNavigation}',
limit: '16.515 KB'
limit: '16.635 KB'
},
{
name: 'import {createLocalizedPathnamesNavigation} from \'next-intl/navigation\' (react-server)',
path: 'dist/production/navigation.react-server.js',
import: '{createLocalizedPathnamesNavigation}',
limit: '16.545 KB'
limit: '16.635 KB'
},
{
name: 'import {createNavigation} from \'next-intl/navigation\' (react-server)',
path: 'dist/production/navigation.react-server.js',
import: '{createNavigation}',
limit: '16.495 KB'
limit: '16.645 KB'
},
{
name: 'import * from \'next-intl/server\' (react-client)',
@@ -55,7 +55,7 @@ const config: SizeLimitConfig = [
{
name: 'import * from \'next-intl/server\' (react-server)',
path: 'dist/production/server.react-server.js',
limit: '13.865 KB'
limit: '13.995 KB'
},
{
name: 'import createMiddleware from \'next-intl/middleware\'',
4 changes: 2 additions & 2 deletions src/navigation/createLocalizedPathnamesNavigation.test.tsx
Original file line number Diff line number Diff line change
@@ -10,7 +10,7 @@ import React from 'react';
import {renderToString} from 'react-dom/server';
import {it, describe, vi, expect, beforeEach} from 'vitest';
import {defineRouting, Pathnames} from '../routing';
import {getRequestLocale} from '../server/react-server/RequestLocale';
import {getRequestLocale} from '../server/react-server/RequestLocaleLegacy';
import {getLocalePrefix} from '../shared/utils';
import createLocalizedPathnamesNavigationClient from './react-client/createLocalizedPathnamesNavigation';
import createLocalizedPathnamesNavigationServer from './react-server/createLocalizedPathnamesNavigation';
@@ -48,7 +48,7 @@ vi.mock('../../src/navigation/react-server/ServerLink', () => ({
);
}
}));
vi.mock('../../src/server/react-server/RequestLocale', () => ({
vi.mock('../../src/server/react-server/RequestLocaleLegacy', () => ({
getRequestLocale: vi.fn(() => 'en')
}));

1 change: 0 additions & 1 deletion src/navigation/createNavigation.test.tsx
Original file line number Diff line number Diff line change
@@ -31,7 +31,6 @@ function mockCurrentLocale(locale: string) {
(localePromise as any).status = 'fulfilled';
(localePromise as any).value = locale;

// @ts-expect-error -- Async values are allowed
vi.mocked(getRequestLocale).mockImplementation(() => localePromise);

vi.mocked(nextUseParams<{locale: string}>).mockImplementation(() => ({
4 changes: 2 additions & 2 deletions src/navigation/createSharedPathnamesNavigation.test.tsx
Original file line number Diff line number Diff line change
@@ -10,7 +10,7 @@ import React from 'react';
import {renderToString} from 'react-dom/server';
import {it, describe, vi, expect, beforeEach} from 'vitest';
import {defineRouting} from '../routing';
import {getRequestLocale} from '../server/react-server/RequestLocale';
import {getRequestLocale} from '../server/react-server/RequestLocaleLegacy';
import {getLocalePrefix} from '../shared/utils';
import createSharedPathnamesNavigationClient from './react-client/createSharedPathnamesNavigation';
import createSharedPathnamesNavigationServer from './react-server/createSharedPathnamesNavigation';
@@ -48,7 +48,7 @@ vi.mock('../../src/navigation/react-server/ServerLink', () => ({
);
}
}));
vi.mock('../../src/server/react-server/RequestLocale', () => ({
vi.mock('../../src/server/react-server/RequestLocaleLegacy', () => ({
getRequestLocale: vi.fn(() => 'en')
}));

Original file line number Diff line number Diff line change
@@ -9,7 +9,7 @@ import {
Locales,
Pathnames
} from '../../routing/types';
import {getRequestLocale} from '../../server/react-server/RequestLocale';
import {getRequestLocale} from '../../server/react-server/RequestLocaleLegacy';
import {ParametersExceptFirst} from '../../shared/types';
import {
HrefOrHrefWithParams,
2 changes: 1 addition & 1 deletion src/navigation/react-server/createNavigation.tsx
Original file line number Diff line number Diff line change
@@ -35,7 +35,7 @@ export default function createNavigation<
type Locale = AppLocales extends never ? string : AppLocales[number];

function getLocale() {
return getRequestLocale() as Locale;
return getRequestLocale() as Promise<Locale>;
}

// eslint-disable-next-line @typescript-eslint/no-unused-vars
2 changes: 1 addition & 1 deletion src/navigation/react-server/redirects.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import {getRequestLocale} from '../../server/react-server/RequestLocale';
import {getRequestLocale} from '../../server/react-server/RequestLocaleLegacy';
import {ParametersExceptFirst} from '../../shared/types';
import {baseRedirect, basePermanentRedirect} from '../shared/redirects';

6 changes: 3 additions & 3 deletions src/server/react-client/index.test.tsx
Original file line number Diff line number Diff line change
@@ -14,8 +14,8 @@ describe('getRequestConfig', () => {
const getConfig = getRequestConfig(({locale}) => ({
messages: {hello: 'Hello ' + locale}
}));
expect(() => getConfig({locale: 'en'})).toThrow(
'`getRequestConfig` is not supported in Client Components.'
);
expect(() =>
getConfig({locale: 'en', requestLocale: Promise.resolve('en')})
).toThrow('`getRequestConfig` is not supported in Client Components.');
});
});
44 changes: 18 additions & 26 deletions src/server/react-server/RequestLocale.tsx
Original file line number Diff line number Diff line change
@@ -1,51 +1,43 @@
import {headers} from 'next/headers';
import {notFound} from 'next/navigation';
import {cache} from 'react';
import {HEADER_LOCALE_NAME} from '../../shared/constants';
import {getCachedRequestLocale} from './RequestLocaleCache';

function getLocaleFromHeaderImpl() {
async function getHeadersImpl(): Promise<Headers> {
const promiseOrValue = headers();

// Compatibility with Next.js <15
return promiseOrValue instanceof Promise
? await promiseOrValue
: promiseOrValue;
}
const getHeaders = cache(getHeadersImpl);

async function getLocaleFromHeaderImpl(): Promise<string | undefined> {
let locale;

try {
locale = headers().get(HEADER_LOCALE_NAME);
locale = (await getHeaders()).get(HEADER_LOCALE_NAME) || undefined;
} catch (error) {
if (
error instanceof Error &&
(error as any).digest === 'DYNAMIC_SERVER_USAGE'
) {
throw new Error(
const wrappedError = new Error(
'Usage of next-intl APIs in Server Components currently opts into dynamic rendering. This limitation will eventually be lifted, but as a stopgap solution, you can use the `unstable_setRequestLocale` API to enable static rendering, see https://next-intl-docs.vercel.app/docs/getting-started/app-router/with-i18n-routing#static-rendering',
{cause: error}
);
(wrappedError as any).digest = (error as any).digest;
throw wrappedError;
} else {
throw error;
}
}

if (!locale) {
if (process.env.NODE_ENV !== 'production') {
console.error(
`\nUnable to find \`next-intl\` locale because the middleware didn't run on this request. See https://next-intl-docs.vercel.app/docs/routing/middleware#unable-to-find-locale. The \`notFound()\` function will be called as a result.\n`
);
}
notFound();
}

return locale;
}
const getLocaleFromHeader = cache(getLocaleFromHeaderImpl);

// Workaround until `createServerContext` is available
function getCacheImpl() {
const value: {locale?: string} = {locale: undefined};
return value;
}
const getCache = cache(getCacheImpl);

export function setRequestLocale(locale: string) {
getCache().locale = locale;
}

export function getRequestLocale(): string {
return getCache().locale || getLocaleFromHeader();
export async function getRequestLocale() {
return getCachedRequestLocale() || (await getLocaleFromHeader());
}
17 changes: 17 additions & 0 deletions src/server/react-server/RequestLocaleCache.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import {cache} from 'react';

// See https://github.com/vercel/next.js/discussions/58862
function getCacheImpl() {
const value: {locale?: string} = {locale: undefined};
return value;
}

const getCache = cache(getCacheImpl);

export function getCachedRequestLocale() {
return getCache().locale;
}

export function setCachedRequestLocale(locale: string) {
getCache().locale = locale;
}
48 changes: 48 additions & 0 deletions src/server/react-server/RequestLocaleLegacy.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import {headers} from 'next/headers';
import {notFound} from 'next/navigation';
import {cache} from 'react';
import {HEADER_LOCALE_NAME} from '../../shared/constants';
import {getCachedRequestLocale} from './RequestLocaleCache';

// This was originally built for Next.js <14, where `headers()` was not async.
// With https://github.com/vercel/next.js/pull/68812, the API became async.
// This file can be removed once we remove the legacy navigation APIs.
function getHeaders() {
return headers();
}

function getLocaleFromHeaderImpl() {
let locale;

try {
locale = getHeaders().get(HEADER_LOCALE_NAME);
} catch (error) {
if (
error instanceof Error &&
(error as any).digest === 'DYNAMIC_SERVER_USAGE'
) {
throw new Error(
'Usage of next-intl APIs in Server Components currently opts into dynamic rendering. This limitation will eventually be lifted, but as a stopgap solution, you can use the `unstable_setRequestLocale` API to enable static rendering, see https://next-intl-docs.vercel.app/docs/getting-started/app-router/with-i18n-routing#static-rendering',
{cause: error}
);
} else {
throw error;
}
}

if (!locale) {
if (process.env.NODE_ENV !== 'production') {
console.error(
`\nUnable to find \`next-intl\` locale because the middleware didn't run on this request. See https://next-intl-docs.vercel.app/docs/routing/middleware#unable-to-find-locale. The \`notFound()\` function will be called as a result.\n`
);
}
notFound();
}

return locale;
}
const getLocaleFromHeader = cache(getLocaleFromHeaderImpl);

export function getRequestLocale(): string {
return getCachedRequestLocale() || getLocaleFromHeader();
}
5 changes: 2 additions & 3 deletions src/server/react-server/createRequestConfig.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,7 @@
// eslint-disable-next-line import/no-extraneous-dependencies
import getRuntimeConfig from 'next-intl/config';
import type {IntlConfig} from 'use-intl/core';
import type {GetRequestConfigParams} from './getRequestConfig';
import type {GetRequestConfigParams, RequestConfig} from './getRequestConfig';

export default getRuntimeConfig as unknown as (
params: GetRequestConfigParams
) => IntlConfig | Promise<IntlConfig>;
) => RequestConfig | Promise<RequestConfig>;
45 changes: 23 additions & 22 deletions src/server/react-server/getConfig.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import {notFound} from 'next/navigation';
import {cache} from 'react';
import {
initializeConfig,
@@ -6,7 +7,9 @@ import {
_createCache
} from 'use-intl/core';
import {getRequestLocale} from './RequestLocale';
import {getRequestLocale as getRequestLocaleLegacy} from './RequestLocaleLegacy';
import createRequestConfig from './createRequestConfig';
import {GetRequestConfigParams} from './getRequestConfig';

// Make sure `now` is consistent across the request in case none was configured
function getDefaultNowImpl() {
@@ -41,15 +44,18 @@ See also: https://next-intl-docs.vercel.app/docs/usage/configuration#i18n-reques
);
}

let hasReadLocale = false;

// In case the consumer doesn't read `params.locale` and instead provides the
// `locale` (either in a single-language workflow or because the locale is
// read from the user settings), don't attempt to read the request locale.
const params = {
const params: GetRequestConfigParams = {
// In case the consumer doesn't read `params.locale` and instead provides the
// `locale` (either in a single-language workflow or because the locale is
// read from the user settings), don't attempt to read the request locale.
get locale() {
hasReadLocale = true;
return localeOverride || getRequestLocale();
return localeOverride || getRequestLocaleLegacy();
},

get requestLocale() {
return localeOverride
? Promise.resolve(localeOverride)
: getRequestLocale();
}
};

@@ -58,25 +64,20 @@ See also: https://next-intl-docs.vercel.app/docs/usage/configuration#i18n-reques
result = await result;
}

if (process.env.NODE_ENV !== 'production') {
if (hasReadLocale) {
if (result.locale) {
console.error(
"\nYou've read the `locale` param that was passed to `getRequestConfig` but have also returned one from the function. This is likely an error, please ensure that you're consistently using a setup with or without i18n routing: https://next-intl-docs.vercel.app/docs/getting-started/app-router\n"
);
}
} else {
if (!result.locale) {
console.error(
"\nYou haven't read the `locale` param that was passed to `getRequestConfig` and also haven't returned one from the function. This is likely an error, please ensure that you're consistently using a setup with or without i18n routing: https://next-intl-docs.vercel.app/docs/getting-started/app-router\n"
);
}
const locale = result.locale || (await params.requestLocale);

if (!locale) {
if (process.env.NODE_ENV !== 'production') {
console.error(
`\nUnable to find \`next-intl\` locale because the middleware didn't run on this request and no \`locale\` was returned in \`getRequestConfig\`. See https://next-intl-docs.vercel.app/docs/routing/middleware#unable-to-find-locale. The \`notFound()\` function will be called as a result.\n`
);
}
notFound();
}

return {
...result,
locale: result.locale || params.locale,
locale,
now: result.now || getDefaultNow(),
timeZone: result.timeZone || getDefaultTimeZone()
};
Loading

0 comments on commit 29394c0

Please sign in to comment.