Skip to content

Commit

Permalink
feat: createNavigation (amannn#1316)
Browse files Browse the repository at this point in the history
This PR provides a new **`createNavigation`** function that supersedes
the previously available APIs:
1. `createSharedPathnamesNavigation`
2. `createLocalizedPathnamesNavigation`

The new function unifies the API for both use cases and also fixes a few
quirks in the previous APIs.

**Usage**

```tsx
import {createNavigation} from 'next-intl/navigation';
import {defineRouting} from 'next-intl/routing';
 
export const routing = defineRouting(/* ... */);
 
export const {Link, redirect, usePathname, useRouter} =
  createNavigation(routing);
```

(see the [updated navigation
docs](https://next-intl-docs-git-feat-create-navigation-next-intl.vercel.app/docs/routing/navigation))

**Improvements**
1. A single API can be used both for shared as well as localized
pathnames. This reduces the API surface and simplifies the corresponding
docs.
2. `Link` can now be composed seamlessly into another component with its
`href` prop without having to add a generic type argument.
3. `getPathname` is now available for both shared as well as localized
pathnames (fixes amannn#785)
4. `router.push` and `redirect` now accept search params consistently
via the object form (e.g. `router.push({pathname: '/users', query:
{sortBy: 'name'})`)—regardless of if you're using shared or localized
pathnames.
5. When using `localePrefix: 'as-necessary'`, the initial render of
`Link` now uses the correct pathname immediately during SSR (fixes
[amannn#444](amannn#444)). Previously, a
prefix for the default locale was added during SSR and removed during
hydration. Also `redirect` now gets the final pathname right without
having to add a superfluous prefix (fixes
[amannn#1335](amannn#1335)). The only
exception is when you use `localePrefix: 'as-necessary'` in combination
with `domains` (see [Special case: Using `domains` with `localePrefix:
'as-needed'`](https://next-intl-docs-git-feat-create-navigation-next-intl.vercel.app/docs/routing#domains-localeprefix-asneeded))
6. `Link` is now compatible with the `asChild` prop of Radix Primitives
when rendered in RSC (see
[amannn#1322](amannn#1322))

**Migrating to `createNavigation`**

`createNavigation` is generally considered a drop-in replacement, but a
few changes might be necessary:
1. `createNavigation` is expected to receive your complete routing
configuration. Ideally, you define this via the
[`defineRouting`](https://next-intl-docs.vercel.app/docs/routing#define-routing)
function and pass the result to `createNavigation`.
2. If you've used `createLocalizedPathnamesNavigation` and have
[composed the `Link` with its `href`
prop](https://next-intl-docs.vercel.app/docs/routing/navigation#link-composition),
you should no longer provide the generic `Pathname` type argument (see
[updated
docs](https://next-intl-docs-git-feat-create-navigation-next-intl.vercel.app/docs/routing/navigation#link-composition)).
```diff
- ComponentProps<typeof Link<Pathname>>
+ ComponentProps<typeof Link>
```
3. If you've used
[`redirect`](https://next-intl-docs.vercel.app/docs/routing/navigation#redirect),
you now have to provide an explicit locale (even if it's just [the
current
locale](https://next-intl-docs.vercel.app/docs/usage/configuration#locale)).
This change was necessary for an upcoming change in Next.js 15 where
`headers()` turns into a promise (see
[amannn#1375](amannn#1375) for details).
```diff
- redirect('/about')
+ redirect({pathname: '/about', locale: 'en'})
```
4. If you've used
[`getPathname`](https://next-intl-docs.vercel.app/docs/routing/navigation#getpathname)
and have previously manually prepended a locale prefix, you should no
longer do so—`getPathname` now takes care of this depending on your
routing strategy.
```diff
- '/'+ locale + getPathname(/* ... */)
+ getPathname(/* ... */);
```
5. If you're using a combination of `localePrefix: 'as-necessary'` and
`domains` and you're using `getPathname`, you now need to provide a
`domain` argument (see [Special case: Using `domains` with
`localePrefix:
'as-needed'`](https://next-intl-docs-git-feat-create-navigation-next-intl.vercel.app/docs/routing#domains-localeprefix-asneeded))
  • Loading branch information
amannn authored Oct 1, 2024
1 parent a2d6478 commit 2d93d65
Show file tree
Hide file tree
Showing 39 changed files with 2,907 additions and 208 deletions.
3 changes: 2 additions & 1 deletion .eslintrc.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,8 @@ module.exports = {
plugins: ['deprecation', 'eslint-plugin-react-compiler'],
rules: {
'import/no-useless-path-segments': 'error',
'react-compiler/react-compiler': 'error'
'react-compiler/react-compiler': 'error',
'@typescript-eslint/ban-types': 'off'
},
overrides: [
{
Expand Down
42 changes: 39 additions & 3 deletions .size-limit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,43 +2,79 @@ import type {SizeLimitConfig} from 'size-limit';

const config: SizeLimitConfig = [
{
name: 'import * from \'next-intl\' (react-client)',
path: 'dist/production/index.react-client.js',
limit: '14.095 KB'
},
{
name: 'import * from \'next-intl\' (react-server)',
path: 'dist/production/index.react-server.js',
limit: '14.665 KB'
},
{
name: 'import {createSharedPathnamesNavigation} from \'next-intl/navigation\' (react-client)',
path: 'dist/production/navigation.react-client.js',
limit: '3.155 KB'
import: '{createSharedPathnamesNavigation}',
limit: '3.885 KB'
},
{
name: 'import {createLocalizedPathnamesNavigation} from \'next-intl/navigation\' (react-client)',
path: 'dist/production/navigation.react-client.js',
import: '{createLocalizedPathnamesNavigation}',
limit: '3.885 KB'
},
{
name: 'import {createNavigation} from \'next-intl/navigation\' (react-client)',
path: 'dist/production/navigation.react-client.js',
import: '{createNavigation}',
limit: '3.885 KB'
},
{
name: 'import {createSharedPathnamesNavigation} from \'next-intl/navigation\' (react-server)',
path: 'dist/production/navigation.react-server.js',
import: '{createSharedPathnamesNavigation}',
limit: '16.515 KB'
},
{
name: 'import {createLocalizedPathnamesNavigation} from \'next-intl/navigation\' (react-server)',
path: 'dist/production/navigation.react-server.js',
import: '{createLocalizedPathnamesNavigation}',
limit: '16.545 KB'
},
{
name: 'import {createNavigation} from \'next-intl/navigation\' (react-server)',
path: 'dist/production/navigation.react-server.js',
limit: '15.845 KB'
import: '{createNavigation}',
limit: '16.495 KB'
},
{
name: 'import * from \'next-intl/server\' (react-client)',
path: 'dist/production/server.react-client.js',
limit: '1 KB'
},
{
name: 'import * from \'next-intl/server\' (react-server)',
path: 'dist/production/server.react-server.js',
limit: '13.865 KB'
},
{
name: 'import createMiddleware from \'next-intl/middleware\'',
path: 'dist/production/middleware.js',
limit: '9.625 KB'
limit: '9.63 KB'
},
{
name: 'import * from \'next-intl/routing\'',
path: 'dist/production/routing.js',
limit: '1 KB'
},
{
name: 'import * from \'next-intl\' (react-client, ESM)',
path: 'dist/esm/index.react-client.js',
import: '*',
limit: '14.265 kB'
},
{
name: 'import {NextIntlProvider} from \'next-intl\' (react-client, ESM)',
path: 'dist/esm/index.react-client.js',
import: '{NextIntlClientProvider}',
limit: '1.425 kB'
Expand Down
21 changes: 18 additions & 3 deletions src/middleware/getAlternateLinksHeaderValue.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
import {NextRequest} from 'next/server';
import {ResolvedRoutingConfig} from '../routing/config';
import {Locales, Pathnames} from '../routing/types';
import {
DomainsConfig,
LocalePrefixMode,
Locales,
Pathnames
} from '../routing/types';
import {normalizeTrailingSlash} from '../shared/utils';
import {
applyBasePath,
Expand All @@ -16,14 +21,24 @@ import {
*/
export default function getAlternateLinksHeaderValue<
AppLocales extends Locales,
AppPathnames extends Pathnames<AppLocales> = never
AppLocalePrefixMode extends LocalePrefixMode,
AppPathnames extends Pathnames<AppLocales> | undefined,
AppDomains extends DomainsConfig<AppLocales> | undefined
>({
localizedPathnames,
request,
resolvedLocale,
routing
}: {
routing: ResolvedRoutingConfig<AppLocales, AppPathnames>;
routing: Omit<
ResolvedRoutingConfig<
AppLocales,
AppLocalePrefixMode,
AppPathnames,
AppDomains
>,
'pathnames'
>;
request: NextRequest;
resolvedLocale: AppLocales[number];
localizedPathnames?: Pathnames<AppLocales>[string];
Expand Down
4 changes: 2 additions & 2 deletions src/middleware/middleware.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1506,7 +1506,7 @@ describe('prefix-based routing', () => {
'renders a localized pathname where the internal pathname was defined with a trailing slash',
(pathname) => {
createMiddleware({
defaultLocale: 'en',
defaultLocale: 'de',
locales: ['de'],
localePrefix: 'always',
pathnames: {
Expand All @@ -1526,7 +1526,7 @@ describe('prefix-based routing', () => {
'redirects a localized pathname where the internal pathname was defined with a trailing slash',
(pathname) => {
createMiddleware({
defaultLocale: 'en',
defaultLocale: 'de',
locales: ['de'],
localePrefix: 'always',
pathnames: {
Expand Down
31 changes: 23 additions & 8 deletions src/middleware/middleware.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
import {NextRequest, NextResponse} from 'next/server';
import {receiveRoutingConfig, RoutingConfig} from '../routing/config';
import {Locales, Pathnames} from '../routing/types';
import {
DomainsConfig,
LocalePrefixMode,
Locales,
Pathnames
} from '../routing/types';
import {HEADER_LOCALE_NAME} from '../shared/constants';
import {
getLocalePrefix,
Expand All @@ -26,9 +31,16 @@ import {

export default function createMiddleware<
AppLocales extends Locales,
AppPathnames extends Pathnames<AppLocales> = never
AppLocalePrefixMode extends LocalePrefixMode = 'always',
AppPathnames extends Pathnames<AppLocales> = never,
AppDomains extends DomainsConfig<AppLocales> = never
>(
routing: RoutingConfig<AppLocales, AppPathnames> &
routing: RoutingConfig<
AppLocales,
AppLocalePrefixMode,
AppPathnames,
AppDomains
> &
// Convenience if `routing` is generated dynamically (i.e. without `defineRouting`)
MiddlewareOptions,
options?: MiddlewareOptions
Expand Down Expand Up @@ -156,16 +168,19 @@ export default function createMiddleware<
let internalTemplateName: keyof AppPathnames | undefined;

let unprefixedInternalPathname = unprefixedExternalPathname;
if ('pathnames' in resolvedRouting) {
const pathnames = (resolvedRouting as any).pathnames as
| AppPathnames
| undefined;
if (pathnames) {
let resolvedTemplateLocale: AppLocales[number] | undefined;
[resolvedTemplateLocale, internalTemplateName] = getInternalTemplate(
resolvedRouting.pathnames,
pathnames,
unprefixedExternalPathname,
locale
);

if (internalTemplateName) {
const pathnameConfig = resolvedRouting.pathnames[internalTemplateName];
const pathnameConfig = pathnames[internalTemplateName];
const localeTemplate: string =
typeof pathnameConfig === 'string'
? pathnameConfig
Expand Down Expand Up @@ -310,8 +325,8 @@ export default function createMiddleware<
getAlternateLinksHeaderValue({
routing: resolvedRouting,
localizedPathnames:
internalTemplateName! != null && 'pathnames' in resolvedRouting
? resolvedRouting.pathnames?.[internalTemplateName]
internalTemplateName! != null && pathnames
? pathnames?.[internalTemplateName]
: undefined,
request,
resolvedLocale: locale
Expand Down
55 changes: 39 additions & 16 deletions src/middleware/resolveLocale.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,8 @@ import {
Locales,
Pathnames,
DomainsConfig,
DomainConfig
DomainConfig,
LocalePrefixMode
} from '../routing/types';
import {COOKIE_LOCALE_NAME} from '../shared/constants';
import {ResolvedMiddlewareOptions} from './config';
Expand Down Expand Up @@ -71,13 +72,23 @@ function getLocaleFromCookie<AppLocales extends Locales>(

function resolveLocaleFromPrefix<
AppLocales extends Locales,
AppPathnames extends Pathnames<AppLocales> = never
AppLocalePrefixMode extends LocalePrefixMode,
AppPathnames extends Pathnames<AppLocales> | undefined,
AppDomains extends DomainsConfig<AppLocales> | undefined
>(
{
defaultLocale,
localePrefix,
locales
}: ResolvedRoutingConfig<AppLocales, AppPathnames>,
}: Omit<
ResolvedRoutingConfig<
AppLocales,
AppLocalePrefixMode,
AppPathnames,
AppDomains
>,
'pathnames'
>,
{localeDetection}: ResolvedMiddlewareOptions,
requestHeaders: Headers,
requestCookies: RequestCookies,
Expand Down Expand Up @@ -110,10 +121,19 @@ function resolveLocaleFromPrefix<

function resolveLocaleFromDomain<
AppLocales extends Locales,
AppPathnames extends Pathnames<AppLocales> = never
AppLocalePrefixMode extends LocalePrefixMode,
AppPathnames extends Pathnames<AppLocales> | undefined,
AppDomains extends DomainsConfig<AppLocales> | undefined
>(
routing: Omit<ResolvedRoutingConfig<AppLocales, AppPathnames>, 'domains'> &
Required<Pick<ResolvedRoutingConfig<AppLocales, AppPathnames>, 'domains'>>,
routing: Omit<
ResolvedRoutingConfig<
AppLocales,
AppLocalePrefixMode,
AppPathnames,
AppDomains
>,
'pathnames'
>,
options: ResolvedMiddlewareOptions,
requestHeaders: Headers,
requestCookies: RequestCookies,
Expand Down Expand Up @@ -188,24 +208,27 @@ function resolveLocaleFromDomain<

export default function resolveLocale<
AppLocales extends Locales,
AppPathnames extends Pathnames<AppLocales> = never
AppLocalePrefixMode extends LocalePrefixMode,
AppPathnames extends Pathnames<AppLocales> | undefined,
AppDomains extends DomainsConfig<AppLocales> | undefined
>(
routing: ResolvedRoutingConfig<AppLocales, AppPathnames>,
routing: Omit<
ResolvedRoutingConfig<
AppLocales,
AppLocalePrefixMode,
AppPathnames,
AppDomains
>,
'pathnames'
>,
options: ResolvedMiddlewareOptions,
requestHeaders: Headers,
requestCookies: RequestCookies,
pathname: string
): {locale: AppLocales[number]; domain?: DomainConfig<AppLocales>} {
if (routing.domains) {
const routingWithDomains = routing as Omit<
ResolvedRoutingConfig<AppLocales, AppPathnames>,
'domains'
> &
Required<
Pick<ResolvedRoutingConfig<AppLocales, AppPathnames>, 'domains'>
>;
return resolveLocaleFromDomain(
routingWithDomains,
routing,
options,
requestHeaders,
requestCookies,
Expand Down
24 changes: 17 additions & 7 deletions src/middleware/utils.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,8 @@ import {
LocalePrefixConfigVerbose,
DomainConfig,
Pathnames,
DomainsConfig
DomainsConfig,
LocalePrefixMode
} from '../routing/types';
import {
getLocalePrefix,
Expand Down Expand Up @@ -92,10 +93,13 @@ export function formatTemplatePathname(
/**
* Removes potential prefixes from the pathname.
*/
export function getNormalizedPathname<AppLocales extends Locales>(
export function getNormalizedPathname<
AppLocales extends Locales,
AppLocalePrefixMode extends LocalePrefixMode
>(
pathname: string,
locales: AppLocales,
localePrefix: LocalePrefixConfigVerbose<AppLocales>
localePrefix: LocalePrefixConfigVerbose<AppLocales, AppLocalePrefixMode>
) {
// Add trailing slash for consistent handling
// both for the root as well as nested paths
Expand Down Expand Up @@ -127,9 +131,12 @@ export function findCaseInsensitiveString(
return strings.find((cur) => cur.toLowerCase() === candidate.toLowerCase());
}

export function getLocalePrefixes<AppLocales extends Locales>(
export function getLocalePrefixes<
AppLocales extends Locales,
AppLocalePrefixMode extends LocalePrefixMode
>(
locales: AppLocales,
localePrefix: LocalePrefixConfigVerbose<AppLocales>,
localePrefix: LocalePrefixConfigVerbose<AppLocales, AppLocalePrefixMode>,
sort = true
): Array<[AppLocales[number], string]> {
const prefixes = locales.map((locale) => [
Expand All @@ -145,10 +152,13 @@ export function getLocalePrefixes<AppLocales extends Locales>(
return prefixes as Array<[AppLocales[number], string]>;
}

export function getPathnameMatch<AppLocales extends Locales>(
export function getPathnameMatch<
AppLocales extends Locales,
AppLocalePrefixMode extends LocalePrefixMode
>(
pathname: string,
locales: AppLocales,
localePrefix: LocalePrefixConfigVerbose<AppLocales>
localePrefix: LocalePrefixConfigVerbose<AppLocales, AppLocalePrefixMode>
):
| {
locale: AppLocales[number];
Expand Down
4 changes: 2 additions & 2 deletions src/navigation/createLocalizedPathnamesNavigation.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import {getRequestLocale} from '../server/react-server/RequestLocale';
import {getLocalePrefix} from '../shared/utils';
import createLocalizedPathnamesNavigationClient from './react-client/createLocalizedPathnamesNavigation';
import createLocalizedPathnamesNavigationServer from './react-server/createLocalizedPathnamesNavigation';
import BaseLink from './shared/BaseLink';
import LegacyBaseLink from './shared/LegacyBaseLink';

vi.mock('next/navigation', async () => {
const actual = await vi.importActual('next/navigation');
Expand All @@ -39,7 +39,7 @@ vi.mock('../../src/navigation/react-server/ServerLink', () => ({
const finalLocale = locale || 'en';
const prefix = getLocalePrefix(finalLocale, localePrefix);
return (
<BaseLink
<LegacyBaseLink
locale={finalLocale}
localePrefixMode={localePrefix.mode}
prefix={prefix}
Expand Down
Loading

0 comments on commit 2d93d65

Please sign in to comment.