diff --git a/docs/pages/docs/routing/middleware.mdx b/docs/pages/docs/routing/middleware.mdx index f85ce8b5d..f2c02c3d2 100644 --- a/docs/pages/docs/routing/middleware.mdx +++ b/docs/pages/docs/routing/middleware.mdx @@ -140,6 +140,8 @@ The bestmatching domain is detected based on these priorities: ### Locale prefix +If you're using [the navigation APIs from `next-intl`](/docs/routing/navigation), you want to make sure your `localePrefix` setting matches your middleware configuration. + #### Always use a locale prefix [#locale-prefix-always] By default, pathnames always start with the locale (e.g. `/en/about`). @@ -200,7 +202,7 @@ In this case, requests for all locales will be rewritten to have the locale only Note that [alternate links](#disable-alternate-links) are disabled in this - mode since there are no distinct URLs per language. + mode since there might not be distinct URLs per locale. ### Disable automatic locale detection diff --git a/docs/pages/docs/routing/navigation.mdx b/docs/pages/docs/routing/navigation.mdx index bb0342c38..9c1b49772 100644 --- a/docs/pages/docs/routing/navigation.mdx +++ b/docs/pages/docs/routing/navigation.mdx @@ -39,19 +39,21 @@ To create [navigation APIs](#apis) for this strategy, use the `createSharedPathn import {createSharedPathnamesNavigation} from 'next-intl/navigation'; export const locales = ['en', 'de'] as const; +export const localePrefix = 'always'; // Default export const {Link, redirect, usePathname, useRouter} = - createSharedPathnamesNavigation({locales}); + createSharedPathnamesNavigation({locales, localePrefix}); ``` -The `locales` argument is identical to the configuration that you pass to the middleware. To reuse it there, you can import the `locales` into the middleware. +The `locales` as well as the `localePrefix` argument is identical to the configuration that you pass to the middleware. You might want to share these values via a central configuration to keep them in sync. ```tsx filename="middleware.ts" import createMiddleware from 'next-intl/middleware'; -import {locales} from './navigation'; +import {locales, localePrefix} from './navigation'; export default createMiddleware({ defaultLocale: 'en', + localePrefix, locales }); ``` @@ -69,6 +71,7 @@ import { } from 'next-intl/navigation'; export const locales = ['en', 'de'] as const; +export const localePrefix = 'always'; // Default // The `pathnames` object holds pairs of internal // and external paths, separated by locale. @@ -99,17 +102,18 @@ export const pathnames = { } satisfies Pathnames; export const {Link, redirect, usePathname, useRouter, getPathname} = - createLocalizedPathnamesNavigation({locales, pathnames}); + createLocalizedPathnamesNavigation({locales, localePrefix, pathnames}); ``` -The `pathnames` argument is identical to the configuration that you pass to the middleware for [localizing pathnames](/docs/routing/middleware#localizing-pathnames). Because of this, you might want to import the `locales` and `pathnames` into the middleware. +The arguments `locales`, `localePrefix` as well as `pathnames` are identical to the configuration that you pass to the middleware. You might want to share these values via a central configuration to make sure they stay in sync. ```tsx filename="middleware.ts" import createMiddleware from 'next-intl/middleware'; -import {locales, pathnames} from './navigation'; +import {locales, localePrefix, pathnames} from './navigation'; export default createMiddleware({ defaultLocale: 'en', + localePrefix, locales, pathnames }); diff --git a/examples/example-app-router-playground/src/middleware.ts b/examples/example-app-router-playground/src/middleware.ts index 4006e7a92..c8954579c 100644 --- a/examples/example-app-router-playground/src/middleware.ts +++ b/examples/example-app-router-playground/src/middleware.ts @@ -1,9 +1,9 @@ import createMiddleware from 'next-intl/middleware'; -import {locales, pathnames} from './navigation'; +import {locales, pathnames, localePrefix} from './navigation'; export default createMiddleware({ defaultLocale: 'en', - localePrefix: 'as-needed', + localePrefix, pathnames, locales }); diff --git a/examples/example-app-router-playground/src/navigation.tsx b/examples/example-app-router-playground/src/navigation.tsx index 6cf704620..d60fd630d 100644 --- a/examples/example-app-router-playground/src/navigation.tsx +++ b/examples/example-app-router-playground/src/navigation.tsx @@ -5,6 +5,8 @@ import { export const locales = ['en', 'de', 'es'] as const; +export const localePrefix = 'as-needed'; + export const pathnames = { '/': '/', '/client': '/client', @@ -25,5 +27,6 @@ export const pathnames = { export const {Link, redirect, usePathname, useRouter} = createLocalizedPathnamesNavigation({ locales, + localePrefix, pathnames }); diff --git a/examples/example-app-router/src/components/LocaleSwitcher.tsx b/examples/example-app-router/src/components/LocaleSwitcher.tsx index 2b07d5a35..7a03cc182 100644 --- a/examples/example-app-router/src/components/LocaleSwitcher.tsx +++ b/examples/example-app-router/src/components/LocaleSwitcher.tsx @@ -1,5 +1,5 @@ import {useLocale, useTranslations} from 'next-intl'; -import {locales} from 'config'; +import {locales} from '../config'; import LocaleSwitcherSelect from './LocaleSwitcherSelect'; export default function LocaleSwitcher() { diff --git a/examples/example-app-router/src/config.ts b/examples/example-app-router/src/config.ts index 626127125..934f7d37f 100644 --- a/examples/example-app-router/src/config.ts +++ b/examples/example-app-router/src/config.ts @@ -10,4 +10,7 @@ export const pathnames = { } } satisfies Pathnames; +// Use the default: `always` +export const localePrefix = undefined; + export type AppPathnames = keyof typeof pathnames; diff --git a/examples/example-app-router/src/middleware.ts b/examples/example-app-router/src/middleware.ts index 099f6717f..874a58ed9 100644 --- a/examples/example-app-router/src/middleware.ts +++ b/examples/example-app-router/src/middleware.ts @@ -1,10 +1,11 @@ import createMiddleware from 'next-intl/middleware'; -import {pathnames, locales} from './config'; +import {pathnames, locales, localePrefix} from './config'; export default createMiddleware({ defaultLocale: 'en', locales, - pathnames + pathnames, + localePrefix }); export const config = { diff --git a/examples/example-app-router/src/navigation.ts b/examples/example-app-router/src/navigation.ts index d98738ffe..44f437407 100644 --- a/examples/example-app-router/src/navigation.ts +++ b/examples/example-app-router/src/navigation.ts @@ -1,8 +1,9 @@ import {createLocalizedPathnamesNavigation} from 'next-intl/navigation'; -import {locales, pathnames} from './config'; +import {locales, pathnames, localePrefix} from './config'; export const {Link, redirect, usePathname, useRouter} = createLocalizedPathnamesNavigation({ locales, - pathnames + pathnames, + localePrefix }); diff --git a/packages/next-intl/package.json b/packages/next-intl/package.json index 5b393091c..79c2c1be6 100644 --- a/packages/next-intl/package.json +++ b/packages/next-intl/package.json @@ -86,6 +86,7 @@ "@types/negotiator": "^0.6.1", "@types/node": "^17.0.23", "@types/react": "18.2.34", + "@types/react-dom": "^18.2.17", "eslint": "^8.54.0", "eslint-config-molindo": "^7.0.0", "eslint-plugin-deprecation": "^1.4.1", @@ -110,11 +111,11 @@ }, { "path": "dist/production/navigation.react-client.js", - "limit": "2.6 KB" + "limit": "2.62 KB" }, { "path": "dist/production/navigation.react-server.js", - "limit": "2.75 KB" + "limit": "2.8 KB" }, { "path": "dist/production/server.js", diff --git a/packages/next-intl/src/middleware/NextIntlMiddlewareConfig.tsx b/packages/next-intl/src/middleware/NextIntlMiddlewareConfig.tsx index 27bd2581d..72396d3be 100644 --- a/packages/next-intl/src/middleware/NextIntlMiddlewareConfig.tsx +++ b/packages/next-intl/src/middleware/NextIntlMiddlewareConfig.tsx @@ -1,6 +1,4 @@ -import {AllLocales, Pathnames} from '../shared/types'; - -type LocalePrefix = 'as-needed' | 'always' | 'never'; +import {AllLocales, LocalePrefix, Pathnames} from '../shared/types'; type RoutingBaseConfig = { /** A list of all locales that are supported. */ diff --git a/packages/next-intl/src/middleware/middleware.tsx b/packages/next-intl/src/middleware/middleware.tsx index ff2ca55dd..3259fb566 100644 --- a/packages/next-intl/src/middleware/middleware.tsx +++ b/packages/next-intl/src/middleware/middleware.tsx @@ -178,13 +178,16 @@ export default function createMiddleware( response = redirect(pathWithSearch); } } else { - const pathWithSearch = getPathWithSearch( + const internalPathWithSearch = getPathWithSearch( pathname, request.nextUrl.search ); if (hasLocalePrefix) { - const basePath = getBasePath(pathWithSearch, pathLocale); + const basePath = getBasePath( + getPathWithSearch(normalizedPathname, request.nextUrl.search), + pathLocale + ); if (configWithDefaults.localePrefix === 'never') { response = redirect(basePath); @@ -205,10 +208,10 @@ export default function createMiddleware( if (domain?.domain !== pathDomain?.domain && !hasUnknownHost) { response = redirect(basePath, pathDomain?.domain); } else { - response = rewrite(pathWithSearch); + response = rewrite(internalPathWithSearch); } } else { - response = rewrite(pathWithSearch); + response = rewrite(internalPathWithSearch); } } } else { @@ -221,9 +224,9 @@ export default function createMiddleware( (configWithDefaults.localePrefix === 'as-needed' || configWithDefaults.domains)) ) { - response = rewrite(`/${locale}${pathWithSearch}`); + response = rewrite(`/${locale}${internalPathWithSearch}`); } else { - response = redirect(`/${locale}${pathWithSearch}`); + response = redirect(`/${locale}${internalPathWithSearch}`); } } } diff --git a/packages/next-intl/src/navigation/react-client/BaseLink.tsx b/packages/next-intl/src/navigation/react-client/ClientLink.tsx similarity index 73% rename from packages/next-intl/src/navigation/react-client/BaseLink.tsx rename to packages/next-intl/src/navigation/react-client/ClientLink.tsx index 96977c479..84f2bc7f9 100644 --- a/packages/next-intl/src/navigation/react-client/BaseLink.tsx +++ b/packages/next-intl/src/navigation/react-client/ClientLink.tsx @@ -1,28 +1,23 @@ import React, {ComponentProps, ReactElement, forwardRef} from 'react'; import useLocale from '../../react-client/useLocale'; -import BaseLinkWithLocale from '../../shared/BaseLinkWithLocale'; import {AllLocales} from '../../shared/types'; +import BaseLink from '../shared/BaseLink'; type Props = Omit< - ComponentProps, + ComponentProps, 'locale' > & { locale?: Locales[number]; }; -function BaseLink( +function ClientLink( {locale, ...rest}: Props, ref: Props['ref'] ) { const defaultLocale = useLocale(); const linkLocale = locale || defaultLocale; return ( - + ); } @@ -46,8 +41,10 @@ function BaseLink( * the `set-cookie` response header would cause the locale cookie on the current * page to be overwritten before the user even decides to change the locale. */ -const BaseLinkWithRef = forwardRef(BaseLink) as ( +const ClientLinkWithRef = forwardRef(ClientLink) as < + Locales extends AllLocales +>( props: Props & {ref?: Props['ref']} ) => ReactElement; -(BaseLinkWithRef as any).displayName = 'Link'; -export default BaseLinkWithRef; +(ClientLinkWithRef as any).displayName = 'ClientLink'; +export default ClientLinkWithRef; diff --git a/packages/next-intl/src/navigation/react-client/baseRedirect.tsx b/packages/next-intl/src/navigation/react-client/clientRedirect.tsx similarity index 60% rename from packages/next-intl/src/navigation/react-client/baseRedirect.tsx rename to packages/next-intl/src/navigation/react-client/clientRedirect.tsx index e9fd47032..9340a576d 100644 --- a/packages/next-intl/src/navigation/react-client/baseRedirect.tsx +++ b/packages/next-intl/src/navigation/react-client/clientRedirect.tsx @@ -1,10 +1,10 @@ import useLocale from '../../react-client/useLocale'; -import redirectWithLocale from '../../shared/redirectWithLocale'; -import {ParametersExceptFirstTwo} from '../../shared/types'; +import {LocalePrefix, ParametersExceptFirstTwo} from '../../shared/types'; +import baseRedirect from '../shared/baseRedirect'; -export default function baseRedirect( - pathname: string, - ...args: ParametersExceptFirstTwo +export default function clientRedirect( + params: {localePrefix?: LocalePrefix; pathname: string}, + ...args: ParametersExceptFirstTwo ) { let locale; try { @@ -18,5 +18,5 @@ export default function baseRedirect( ); } - return redirectWithLocale(pathname, locale, ...args); + return baseRedirect({...params, locale}, ...args); } diff --git a/packages/next-intl/src/navigation/react-client/createLocalizedPathnamesNavigation.tsx b/packages/next-intl/src/navigation/react-client/createLocalizedPathnamesNavigation.tsx index 9da45f0a8..26f817ecc 100644 --- a/packages/next-intl/src/navigation/react-client/createLocalizedPathnamesNavigation.tsx +++ b/packages/next-intl/src/navigation/react-client/createLocalizedPathnamesNavigation.tsx @@ -1,6 +1,11 @@ import React, {ComponentProps, ReactElement, forwardRef} from 'react'; import useLocale from '../../react-client/useLocale'; -import {AllLocales, ParametersExceptFirst, Pathnames} from '../../shared/types'; +import { + AllLocales, + LocalePrefix, + ParametersExceptFirst, + Pathnames +} from '../../shared/types'; import { compileLocalizedPathname, getRoute, @@ -8,18 +13,22 @@ import { HrefOrHrefWithParams, HrefOrUrlObjectWithParams } from '../shared/utils'; -import BaseLink from './BaseLink'; -import baseRedirect from './baseRedirect'; +import ClientLink from './ClientLink'; +import clientRedirect from './clientRedirect'; import useBasePathname from './useBasePathname'; import useBaseRouter from './useBaseRouter'; export default function createLocalizedPathnamesNavigation< Locales extends AllLocales, PathnamesConfig extends Pathnames ->({locales, pathnames}: {locales: Locales; pathnames: PathnamesConfig}) { - function useTypedLocale(): (typeof locales)[number] { +>(opts: { + locales: Locales; + pathnames: PathnamesConfig; + localePrefix?: LocalePrefix; +}) { + function useTypedLocale(): (typeof opts.locales)[number] { const locale = useLocale(); - const isValid = locales.includes(locale as any); + const isValid = opts.locales.includes(locale as any); if (!isValid) { throw new Error( process.env.NODE_ENV !== 'production' @@ -31,7 +40,7 @@ export default function createLocalizedPathnamesNavigation< } type LinkProps = Omit< - ComponentProps, + ComponentProps, 'href' | 'name' > & { href: HrefOrUrlObjectWithParams; @@ -39,13 +48,13 @@ export default function createLocalizedPathnamesNavigation< }; function Link( {href, locale, ...rest}: LinkProps, - ref?: ComponentProps['ref'] + ref?: ComponentProps['ref'] ) { const defaultLocale = useTypedLocale(); const finalLocale = locale || defaultLocale; return ( - ({ locale: finalLocale, @@ -53,9 +62,10 @@ export default function createLocalizedPathnamesNavigation< pathname: href, // @ts-expect-error -- This is ok params: typeof href === 'object' ? href.params : undefined, - pathnames + pathnames: opts.pathnames })} locale={locale} + localePrefix={opts.localePrefix} {...rest} /> ); @@ -63,18 +73,20 @@ export default function createLocalizedPathnamesNavigation< const LinkWithRef = forwardRef(Link) as unknown as < Pathname extends keyof PathnamesConfig >( - props: LinkProps & {ref?: ComponentProps['ref']} + props: LinkProps & { + ref?: ComponentProps['ref']; + } ) => ReactElement; (LinkWithRef as any).displayName = 'Link'; function redirect( href: HrefOrHrefWithParams, - ...args: ParametersExceptFirst + ...args: ParametersExceptFirst ) { // eslint-disable-next-line react-hooks/rules-of-hooks -- Reading from context here is fine, since `redirect` should be called during render const locale = useTypedLocale(); const resolvedHref = getPathname({href, locale}); - return baseRedirect(resolvedHref, ...args); + return clientRedirect({...opts, pathname: resolvedHref}, ...args); } function useRouter() { @@ -121,7 +133,7 @@ export default function createLocalizedPathnamesNavigation< function usePathname(): keyof PathnamesConfig { const pathname = useBasePathname(); const locale = useTypedLocale(); - return getRoute({pathname, locale, pathnames}); + return getRoute({pathname, locale, pathnames: opts.pathnames}); } function getPathname({ @@ -134,7 +146,7 @@ export default function createLocalizedPathnamesNavigation< return compileLocalizedPathname({ ...normalizeNameOrNameWithParams(href), locale, - pathnames + pathnames: opts.pathnames }); } diff --git a/packages/next-intl/src/navigation/react-client/createSharedPathnamesNavigation.tsx b/packages/next-intl/src/navigation/react-client/createSharedPathnamesNavigation.tsx index b56cd6f09..230dc75e4 100644 --- a/packages/next-intl/src/navigation/react-client/createSharedPathnamesNavigation.tsx +++ b/packages/next-intl/src/navigation/react-client/createSharedPathnamesNavigation.tsx @@ -1,16 +1,45 @@ -import {AllLocales} from '../../shared/types'; -import BaseLink from './BaseLink'; -import baseRedirect from './baseRedirect'; +import React, {ComponentProps, ReactElement, forwardRef} from 'react'; +import { + AllLocales, + LocalePrefix, + ParametersExceptFirst +} from '../../shared/types'; +import ClientLink from './ClientLink'; +import clientRedirect from './clientRedirect'; import useBasePathname from './useBasePathname'; import useBaseRouter from './useBaseRouter'; export default function createSharedPathnamesNavigation< Locales extends AllLocales - // eslint-disable-next-line @typescript-eslint/no-unused-vars -- The value is not used yet, only the type information is important ->(opts: {locales: Locales}) { +>(opts: {locales: Locales; localePrefix?: LocalePrefix}) { + type LinkProps = Omit< + ComponentProps>, + 'localePrefix' + >; + function Link(props: LinkProps, ref: LinkProps['ref']) { + return ( + + ref={ref} + localePrefix={opts.localePrefix} + {...props} + /> + ); + } + const LinkWithRef = forwardRef(Link) as ( + props: LinkProps & {ref?: LinkProps['ref']} + ) => ReactElement; + (LinkWithRef as any).displayName = 'Link'; + + function redirect( + pathname: string, + ...args: ParametersExceptFirst + ) { + return clientRedirect({...opts, pathname}, ...args); + } + return { - Link: BaseLink as typeof BaseLink, - redirect: baseRedirect, + Link: LinkWithRef, + redirect, usePathname: useBasePathname, useRouter: useBaseRouter }; diff --git a/packages/next-intl/src/navigation/react-server/BaseLink.tsx b/packages/next-intl/src/navigation/react-server/ServerLink.tsx similarity index 50% rename from packages/next-intl/src/navigation/react-server/BaseLink.tsx rename to packages/next-intl/src/navigation/react-server/ServerLink.tsx index f671f7cdc..384e25b6c 100644 --- a/packages/next-intl/src/navigation/react-server/BaseLink.tsx +++ b/packages/next-intl/src/navigation/react-server/ServerLink.tsx @@ -1,20 +1,18 @@ import React, {ComponentProps} from 'react'; import {getLocale} from '../../server'; -import BaseLinkWithLocale from '../../shared/BaseLinkWithLocale'; import {AllLocales} from '../../shared/types'; +import BaseLink from '../shared/BaseLink'; type Props = Omit< - ComponentProps, + ComponentProps, 'locale' > & { locale?: Locales[number]; }; -export default async function BaseLink({ +export default async function ServerLink({ locale, ...rest }: Props) { - return ( - - ); + return ; } diff --git a/packages/next-intl/src/navigation/react-server/baseRedirect.tsx b/packages/next-intl/src/navigation/react-server/baseRedirect.tsx deleted file mode 100644 index 0a1705b64..000000000 --- a/packages/next-intl/src/navigation/react-server/baseRedirect.tsx +++ /dev/null @@ -1,11 +0,0 @@ -import {getRequestLocale} from '../../server/RequestLocale'; -import redirectWithLocale from '../../shared/redirectWithLocale'; -import {ParametersExceptFirstTwo} from '../../shared/types'; - -export default function baseRedirect( - pathname: string, - ...args: ParametersExceptFirstTwo -) { - const locale = getRequestLocale(); - return redirectWithLocale(pathname, locale, ...args); -} diff --git a/packages/next-intl/src/navigation/react-server/createLocalizedPathnamesNavigation.tsx b/packages/next-intl/src/navigation/react-server/createLocalizedPathnamesNavigation.tsx index 7f1ea7e39..5b233f4da 100644 --- a/packages/next-intl/src/navigation/react-server/createLocalizedPathnamesNavigation.tsx +++ b/packages/next-intl/src/navigation/react-server/createLocalizedPathnamesNavigation.tsx @@ -1,21 +1,34 @@ import React, {ComponentProps} from 'react'; import {getRequestLocale} from '../../server/RequestLocale'; -import {AllLocales, ParametersExceptFirst, Pathnames} from '../../shared/types'; +import { + AllLocales, + LocalePrefix, + ParametersExceptFirst, + Pathnames +} from '../../shared/types'; import { HrefOrHrefWithParams, HrefOrUrlObjectWithParams, compileLocalizedPathname, normalizeNameOrNameWithParams } from '../shared/utils'; -import BaseLink from './BaseLink'; -import baseRedirect from './baseRedirect'; +import ServerLink from './ServerLink'; +import serverRedirect from './serverRedirect'; export default function createLocalizedPathnamesNavigation< Locales extends AllLocales, PathnamesConfig extends Pathnames ->({locales, pathnames}: {locales: Locales; pathnames: Pathnames}) { +>({ + localePrefix, + locales, + pathnames +}: { + locales: Locales; + pathnames: Pathnames; + localePrefix?: LocalePrefix; +}) { type LinkProps = Omit< - ComponentProps, + ComponentProps, 'href' | 'name' > & { href: HrefOrUrlObjectWithParams; @@ -30,7 +43,7 @@ export default function createLocalizedPathnamesNavigation< const finalLocale = locale || defaultLocale; return ( - ({ locale: finalLocale, // @ts-expect-error -- This is ok @@ -40,6 +53,7 @@ export default function createLocalizedPathnamesNavigation< pathnames })} locale={locale} + localePrefix={localePrefix} {...rest} /> ); @@ -47,11 +61,11 @@ export default function createLocalizedPathnamesNavigation< function redirect( href: HrefOrHrefWithParams, - ...args: ParametersExceptFirst + ...args: ParametersExceptFirst ) { const locale = getRequestLocale(); - const resolvedHref = getPathname({href, locale}); - return baseRedirect(resolvedHref, ...args); + const pathname = getPathname({href, locale}); + return serverRedirect({localePrefix, pathname}, ...args); } function getPathname({ diff --git a/packages/next-intl/src/navigation/react-server/createSharedPathnamesNavigation.tsx b/packages/next-intl/src/navigation/react-server/createSharedPathnamesNavigation.tsx index 58458c2cd..46eebbd7b 100644 --- a/packages/next-intl/src/navigation/react-server/createSharedPathnamesNavigation.tsx +++ b/packages/next-intl/src/navigation/react-server/createSharedPathnamesNavigation.tsx @@ -1,11 +1,15 @@ -import {AllLocales} from '../../shared/types'; -import BaseLink from './BaseLink'; -import baseRedirect from './baseRedirect'; +import React, {ComponentProps} from 'react'; +import { + AllLocales, + LocalePrefix, + ParametersExceptFirst +} from '../../shared/types'; +import ServerLink from './ServerLink'; +import serverRedirect from './serverRedirect'; export default function createSharedPathnamesNavigation< Locales extends AllLocales - // eslint-disable-next-line @typescript-eslint/no-unused-vars -- The value is not used yet, only the type information is important ->(opts: {locales: Locales}) { +>(opts: {locales: Locales; localePrefix?: LocalePrefix}) { function notSupported(message: string) { return () => { throw new Error( @@ -14,9 +18,20 @@ export default function createSharedPathnamesNavigation< }; } + function Link(props: ComponentProps>) { + return localePrefix={opts.localePrefix} {...props} />; + } + + function redirect( + pathname: string, + ...args: ParametersExceptFirst + ) { + return serverRedirect({...opts, pathname}, ...args); + } + return { - Link: BaseLink, - redirect: baseRedirect, + Link, + redirect, usePathname: notSupported('usePathname'), useRouter: notSupported('useRouter') }; diff --git a/packages/next-intl/src/navigation/react-server/serverRedirect.tsx b/packages/next-intl/src/navigation/react-server/serverRedirect.tsx new file mode 100644 index 000000000..5badc95be --- /dev/null +++ b/packages/next-intl/src/navigation/react-server/serverRedirect.tsx @@ -0,0 +1,11 @@ +import {getRequestLocale} from '../../server/RequestLocale'; +import {LocalePrefix, ParametersExceptFirstTwo} from '../../shared/types'; +import baseRedirect from '../shared/baseRedirect'; + +export default function serverRedirect( + params: {pathname: string; localePrefix?: LocalePrefix}, + ...args: ParametersExceptFirstTwo +) { + const locale = getRequestLocale(); + return baseRedirect({...params, locale}, ...args); +} diff --git a/packages/next-intl/src/shared/BaseLinkWithLocale.tsx b/packages/next-intl/src/navigation/shared/BaseLink.tsx similarity index 50% rename from packages/next-intl/src/shared/BaseLinkWithLocale.tsx rename to packages/next-intl/src/navigation/shared/BaseLink.tsx index df5d01b41..2cbe953bf 100644 --- a/packages/next-intl/src/shared/BaseLinkWithLocale.tsx +++ b/packages/next-intl/src/navigation/shared/BaseLink.tsx @@ -3,15 +3,17 @@ import NextLink from 'next/link'; import {usePathname} from 'next/navigation'; import React, {ComponentProps, forwardRef, useEffect, useState} from 'react'; -import useLocale from '../react-client/useLocale'; -import {isLocalHref, localizeHref, prefixHref} from './utils'; +import useLocale from '../../react-client/useLocale'; +import {LocalePrefix} from '../../shared/types'; +import {isLocalHref, localizeHref, prefixHref} from '../../shared/utils'; type Props = Omit, 'locale'> & { locale: string; + localePrefix?: LocalePrefix; }; -function BaseLinkWithLocale( - {href, locale, prefetch, ...rest}: Props, +function BaseLink( + {href, locale, localePrefix, prefetch, ...rest}: Props, ref: Props['ref'] ) { // The types aren't entirely correct here. Outside of Next.js @@ -22,26 +24,28 @@ function BaseLinkWithLocale( const isChangingLocale = locale !== defaultLocale; const [localizedHref, setLocalizedHref] = useState(() => - isLocalHref(href) && locale - ? // Potentially the href shouldn't be prefixed, but to determine this we + isLocalHref(href) && (localePrefix !== 'never' || isChangingLocale) + ? // For the `localePrefix: 'as-needed' strategy, the href shouldn't + // be prefixed if the locale is the default locale. To determine this, we // need a) the default locale and b) the information if we use prefixed - // routing. During the server side render (both in RSC as well as SSR), - // we don't have this information. Therefore we always prefix the href - // since this will always result in a valid URL, even if it might cause - // a redirect. This is better than pointing to a non-localized href - // during the server render, which would potentially be wrong. The final - // href is determined in the effect below. + // routing. The default locale can vary by domain, therefore during the + // RSC as well as the SSR render, we can't determine the default locale + // statically. Therefore we always prefix the href since this will + // always result in a valid URL, even if it might cause a redirect. This + // is better than pointing to a non-localized href during the server + // render, which would potentially be wrong. The final href is + // determined in the effect below. prefixHref(href, locale) : href ); useEffect(() => { - if (!pathname) return; + if (!pathname || localePrefix === 'never') return; setLocalizedHref( localizeHref(href, locale, defaultLocale, pathname ?? undefined) ); - }, [defaultLocale, href, locale, pathname]); + }, [defaultLocale, href, locale, localePrefix, pathname]); if (isChangingLocale) { if (prefetch && process.env.NODE_ENV !== 'production') { @@ -57,4 +61,6 @@ function BaseLinkWithLocale( ); } -export default forwardRef(BaseLinkWithLocale); +const BaseLinkWithRef = forwardRef(BaseLink); +(BaseLinkWithRef as any).displayName = 'ClientLink'; +export default BaseLinkWithRef; diff --git a/packages/next-intl/src/navigation/shared/StrictParams.tsx b/packages/next-intl/src/navigation/shared/StrictParams.tsx index ed4332b02..7606054c0 100644 --- a/packages/next-intl/src/navigation/shared/StrictParams.tsx +++ b/packages/next-intl/src/navigation/shared/StrictParams.tsx @@ -11,16 +11,16 @@ type ReadUntil = Path extends `${infer Match}]${infer Rest}` type RemovePrefixes = Key extends `[...${infer Name}` ? Name : Key extends `...${infer Name}` - ? Name - : Key; + ? Name + : Key; type StrictParams = Pathname extends `${string}[${string}` ? { [Key in ReadFrom[number] as RemovePrefixes]: Key extends `[...${string}` ? Array | undefined : Key extends `...${string}` - ? Array - : ParamValue; + ? Array + : ParamValue; } : never; diff --git a/packages/next-intl/src/navigation/shared/baseRedirect.tsx b/packages/next-intl/src/navigation/shared/baseRedirect.tsx new file mode 100644 index 000000000..6127dc29e --- /dev/null +++ b/packages/next-intl/src/navigation/shared/baseRedirect.tsx @@ -0,0 +1,22 @@ +import {redirect as nextRedirect} from 'next/navigation'; +import { + AllLocales, + LocalePrefix, + ParametersExceptFirst +} from '../../shared/types'; +import {prefixPathname} from '../../shared/utils'; + +export default function baseRedirect( + params: { + pathname: string; + locale: AllLocales[number]; + localePrefix?: LocalePrefix; + }, + ...args: ParametersExceptFirst +) { + const localizedPathname = + params.localePrefix === 'never' + ? params.pathname + : prefixPathname(params.locale, params.pathname); + return nextRedirect(localizedPathname, ...args); +} diff --git a/packages/next-intl/src/navigation/shared/utils.tsx b/packages/next-intl/src/navigation/shared/utils.tsx index c4acf6999..2c8bf21eb 100644 --- a/packages/next-intl/src/navigation/shared/utils.tsx +++ b/packages/next-intl/src/navigation/shared/utils.tsx @@ -13,10 +13,10 @@ type HrefOrHrefWithParamsImpl = ? // Optional catch-all Pathname | ({pathname: Pathname; params?: StrictParams} & Other) : Pathname extends `${string}[${string}` - ? // Required catch-all & regular params - {pathname: Pathname; params: StrictParams} & Other - : // No params - Pathname | ({pathname: Pathname} & Other); + ? // Required catch-all & regular params + {pathname: Pathname; params: StrictParams} & Other + : // No params + Pathname | ({pathname: Pathname} & Other); export type HrefOrUrlObjectWithParams = HrefOrHrefWithParamsImpl< Pathname, diff --git a/packages/next-intl/src/shared/redirectWithLocale.tsx b/packages/next-intl/src/shared/redirectWithLocale.tsx deleted file mode 100644 index f14eb9e81..000000000 --- a/packages/next-intl/src/shared/redirectWithLocale.tsx +++ /dev/null @@ -1,12 +0,0 @@ -import {redirect as nextRedirect} from 'next/navigation'; -import {AllLocales, ParametersExceptFirst} from './types'; -import {localizePathname} from './utils'; - -export default function redirectWithLocale( - pathname: string, - locale: AllLocales[number], - ...args: ParametersExceptFirst -) { - const localizedPathname = localizePathname(locale, pathname); - return nextRedirect(localizedPathname, ...args); -} diff --git a/packages/next-intl/src/shared/types.tsx b/packages/next-intl/src/shared/types.tsx index 0a628c03a..590d4d1fb 100644 --- a/packages/next-intl/src/shared/types.tsx +++ b/packages/next-intl/src/shared/types.tsx @@ -1,5 +1,7 @@ export type AllLocales = ReadonlyArray; +export type LocalePrefix = 'as-needed' | 'always' | 'never'; + export type Pathnames = Record< string, {[Key in Locales[number]]: string} | string diff --git a/packages/next-intl/src/shared/utils.tsx b/packages/next-intl/src/shared/utils.tsx index 36ae5ab69..cacc52f88 100644 --- a/packages/next-intl/src/shared/utils.tsx +++ b/packages/next-intl/src/shared/utils.tsx @@ -21,29 +21,29 @@ export function isLocalHref(href: Href) { export function localizeHref( href: string, locale: string, - defaultLocale: string, + curLocale: string, pathname: string ): string; export function localizeHref( href: UrlObject | string, locale: string, - defaultLocale: string, + curLocale: string, pathname: string ): UrlObject | string; export function localizeHref( href: UrlObject | string, locale: string, - defaultLocale: string = locale, + curLocale: string = locale, pathname: string ) { if (!isLocalHref(href) || isRelativeHref(href)) { return href; } - const isSwitchingLocale = locale !== defaultLocale; + const isSwitchingLocale = locale !== curLocale; const isPathnamePrefixed = locale == null || hasPathnamePrefixed(locale, pathname); - const shouldPrefix = isPathnamePrefixed || isSwitchingLocale; + const shouldPrefix = isSwitchingLocale || isPathnamePrefixed; if (shouldPrefix && locale != null) { return prefixHref(href, locale); @@ -60,11 +60,11 @@ export function prefixHref( export function prefixHref(href: UrlObject | string, locale: string) { let prefixedHref; if (typeof href === 'string') { - prefixedHref = localizePathname(locale, href); + prefixedHref = prefixPathname(locale, href); } else { prefixedHref = {...href}; if (href.pathname) { - prefixedHref.pathname = localizePathname(locale, href.pathname); + prefixedHref.pathname = prefixPathname(locale, href.pathname); } } @@ -75,7 +75,7 @@ export function unlocalizePathname(pathname: string, locale: string) { return pathname.replace(new RegExp(`^/${locale}`), '') || '/'; } -export function localizePathname(locale: string, pathname: string) { +export function prefixPathname(locale: string, pathname: string) { let localizedHref = '/' + locale; // Avoid trailing slashes diff --git a/packages/next-intl/test/middleware/middleware.test.tsx b/packages/next-intl/test/middleware/middleware.test.tsx index cb41d2877..0afc09db7 100644 --- a/packages/next-intl/test/middleware/middleware.test.tsx +++ b/packages/next-intl/test/middleware/middleware.test.tsx @@ -564,6 +564,37 @@ describe('prefix-based routing', () => { ); }); }); + + describe('localized pathnames with different pathnames for internal and external pathnames for the default locale', () => { + const middlewareWithPathnames = createIntlMiddleware({ + defaultLocale: 'en', + locales: ['en', 'de'], + localePrefix: 'as-needed', + pathnames: { + '/internal': '/external' + } satisfies Pathnames> + }); + + it('redirects a request for a localized route to remove the locale prefix while keeping search params at the root', () => { + middlewareWithPathnames(createMockRequest('/en?hello', 'en')); + expect(MockedNextResponse.next).not.toHaveBeenCalled(); + expect(MockedNextResponse.rewrite).not.toHaveBeenCalled(); + expect(MockedNextResponse.redirect).toHaveBeenCalledTimes(1); + expect(MockedNextResponse.redirect.mock.calls[0][0].toString()).toBe( + 'http://localhost:3000/?hello' + ); + }); + + it('redirects a request for a localized route to remove the locale prefix while keeping search params', () => { + middlewareWithPathnames(createMockRequest('/en/external?hello', 'en')); + expect(MockedNextResponse.next).not.toHaveBeenCalled(); + expect(MockedNextResponse.rewrite).not.toHaveBeenCalled(); + expect(MockedNextResponse.redirect).toHaveBeenCalledTimes(1); + expect(MockedNextResponse.redirect.mock.calls[0][0].toString()).toBe( + 'http://localhost:3000/external?hello' + ); + }); + }); }); describe('localePrefix: as-needed, localeDetection: false', () => { @@ -1124,6 +1155,16 @@ describe('prefix-based routing', () => { 'http://localhost:3000/en/users/12' ); }); + + it('redirects a request for a localized route to remove the locale prefix while keeping search params', () => { + middlewareWithPathnames(createMockRequest('/de/ueber?hello', 'de')); + expect(MockedNextResponse.next).not.toHaveBeenCalled(); + expect(MockedNextResponse.rewrite).not.toHaveBeenCalled(); + expect(MockedNextResponse.redirect).toHaveBeenCalledTimes(1); + expect(MockedNextResponse.redirect.mock.calls[0][0].toString()).toBe( + 'http://localhost:3000/ueber?hello' + ); + }); }); }); }); diff --git a/packages/next-intl/test/navigation/createLocalizedPathnamesNavigation.test.tsx b/packages/next-intl/test/navigation/createLocalizedPathnamesNavigation.test.tsx new file mode 100644 index 000000000..a91b319d8 --- /dev/null +++ b/packages/next-intl/test/navigation/createLocalizedPathnamesNavigation.test.tsx @@ -0,0 +1,435 @@ +import {render, screen} from '@testing-library/react'; +import { + usePathname as useNextPathname, + useParams, + redirect as nextRedirect +} from 'next/navigation'; +import React from 'react'; +import {renderToString} from 'react-dom/server'; +import {it, describe, vi, expect, beforeEach} from 'vitest'; +import createLocalizedPathnamesNavigationClient from '../../src/navigation/react-client/createLocalizedPathnamesNavigation'; +import createLocalizedPathnamesNavigationServer from '../../src/navigation/react-server/createLocalizedPathnamesNavigation'; +import BaseLink from '../../src/navigation/shared/BaseLink'; +import {Pathnames} from '../../src/navigation.react-client'; +import {getRequestLocale} from '../../src/server/RequestLocale'; + +vi.mock('next/navigation'); +vi.mock('next-intl/config', () => ({ + default: async () => + ((await vi.importActual('../../src/server')) as any).getRequestConfig({ + locale: 'en' + }) +})); +vi.mock('react', async (importOriginal) => ({ + ...((await importOriginal()) as typeof import('react')), + cache(fn: (...args: Array) => unknown) { + return (...args: Array) => fn(...args); + } +})); +// Avoids handling an async component (not supported by renderToString) +vi.mock('../../src/navigation/react-server/ServerLink', () => ({ + default({locale, ...rest}: any) { + return ; + } +})); +vi.mock('../../src/server/RequestLocale', () => ({ + getRequestLocale: vi.fn(() => 'en') +})); + +beforeEach(() => { + // usePathname from Next.js returns the pathname the user sees + // (i.e. the external one that might be localized) + vi.mocked(useNextPathname).mockImplementation(() => '/'); + + vi.mocked(getRequestLocale).mockImplementation(() => 'en'); + vi.mocked(useParams).mockImplementation(() => ({locale: 'en'})); +}); + +const locales = ['en', 'de'] as const; +const pathnames = { + '/': '/', + '/about': { + en: '/about', + de: '/ueber-uns' + }, + '/news/[articleSlug]-[articleId]': { + en: '/news/[articleSlug]-[articleId]', + de: '/neuigkeiten/[articleSlug]-[articleId]' + }, + '/categories/[...parts]': { + en: '/categories/[...parts]', + de: '/kategorien/[...parts]' + }, + '/catch-all/[[...parts]]': '/catch-all/[[...parts]]' +} satisfies Pathnames; + +describe.each([ + { + env: 'react-client', + implementation: createLocalizedPathnamesNavigationClient + }, + { + env: 'react-server', + implementation: createLocalizedPathnamesNavigationServer + } +])( + 'createLocalizedPathnamesNavigation ($env)', + ({implementation: createLocalizedPathnamesNavigation}) => { + describe("localePrefix: 'always'", () => { + const {Link} = createLocalizedPathnamesNavigation({ + pathnames, + locales, + localePrefix: 'always' + }); + describe('Link', () => { + it('renders a prefix for the default locale', () => { + const markup = renderToString(About); + expect(markup).toContain('href="/en/about"'); + }); + + it('renders a prefix for a different locale', () => { + const markup = renderToString( + + Über uns + + ); + expect(markup).toContain('href="/de/ueber-uns"'); + }); + + it('renders an object href', () => { + render( + About + ); + expect( + screen.getByRole('link', {name: 'About'}).getAttribute('href') + ).toBe('/about?foo=bar'); + }); + }); + }); + + describe("localePrefix: 'as-needed'", () => { + const {Link, getPathname, redirect} = createLocalizedPathnamesNavigation({ + locales, + pathnames, + localePrefix: 'as-needed' + }); + + describe('Link', () => { + it('renders a prefix for the default locale initially', () => { + const markup = renderToString(About); + expect(markup).toContain('href="/en/about"'); + }); + + it("doesn't render a prefix for the default locale eventually", () => { + render(Über uns); + expect(screen.getByText('Über uns').getAttribute('href')).toBe( + '/about' + ); + }); + + it('adds a prefix when linking to a non-default locale', () => { + render( + + Über uns + + ); + expect( + screen.getByRole('link', {name: 'Über uns'}).getAttribute('href') + ).toBe('/de/ueber-uns'); + }); + + it('handles params', () => { + render( + + About + + ); + expect( + screen.getByRole('link', {name: 'About'}).getAttribute('href') + ).toBe('/de/neuigkeiten/launch-party-3'); + }); + + it('handles catch-all segments', () => { + render( + + Test + + ); + expect( + screen.getByRole('link', {name: 'Test'}).getAttribute('href') + ).toBe('/categories/clothing/t-shirts'); + }); + + it('handles optional catch-all segments', () => { + render( + + Test + + ); + expect( + screen.getByRole('link', {name: 'Test'}).getAttribute('href') + ).toBe('/catch-all/one/two'); + }); + + it('supports optional search params', () => { + render( + + Test + + ); + expect( + screen.getByRole('link', {name: 'Test'}).getAttribute('href') + ).toBe('/about?foo=bar&bar=1&bar=2'); + }); + + it('handles unknown routes', () => { + // @ts-expect-error -- Unknown route + const {rerender} = render(Unknown); + expect( + screen.getByRole('link', {name: 'Unknown'}).getAttribute('href') + ).toBe('/unknown'); + + rerender( + // @ts-expect-error -- Unknown route + + Unknown + + ); + expect( + screen.getByRole('link', {name: 'Unknown'}).getAttribute('href') + ).toBe('/de/unknown'); + }); + }); + + describe('redirect', () => { + function Component({ + href + }: { + href: Parameters>[0]; + }) { + redirect(href); + return null; + } + + it('can redirect for the default locale', () => { + vi.mocked(useNextPathname).mockImplementation(() => '/'); + const {rerender} = render(); + expect(nextRedirect).toHaveBeenLastCalledWith('/en'); + + rerender(); + expect(nextRedirect).toHaveBeenLastCalledWith('/en/about'); + + rerender( + + ); + expect(nextRedirect).toHaveBeenLastCalledWith( + '/en/news/launch-party-3' + ); + }); + + it('can redirect for a non-default locale', () => { + vi.mocked(useParams).mockImplementation(() => ({locale: 'de'})); + vi.mocked(getRequestLocale).mockImplementation(() => 'de'); + vi.mocked(useNextPathname).mockImplementation(() => '/'); + + const {rerender} = render(); + expect(nextRedirect).toHaveBeenLastCalledWith('/de'); + + rerender(); + expect(nextRedirect).toHaveBeenLastCalledWith('/de/ueber-uns'); + + rerender( + + ); + expect(nextRedirect).toHaveBeenLastCalledWith( + '/de/neuigkeiten/launch-party-3' + ); + }); + + it('supports optional search params', () => { + vi.mocked(useNextPathname).mockImplementation(() => '/'); + render( + + ); + expect(nextRedirect).toHaveBeenLastCalledWith( + '/en?foo=bar&bar=1&bar=2' + ); + }); + + it('handles unknown routes', () => { + vi.mocked(useNextPathname).mockImplementation(() => '/'); + // @ts-expect-error -- Unknown route + render(); + expect(nextRedirect).toHaveBeenLastCalledWith('/en/unknown'); + }); + }); + + describe('getPathname', () => { + it('resolves to the correct path', () => { + expect( + getPathname({ + locale: 'en', + href: { + pathname: '/categories/[...parts]', + params: {parts: ['clothing', 't-shirts']}, + query: {sort: 'price'} + } + }) + ).toBe('/categories/clothing/t-shirts?sort=price'); + }); + }); + }); + + describe("localePrefix: 'never'", () => { + const {Link, redirect} = createLocalizedPathnamesNavigation({ + pathnames, + locales, + localePrefix: 'never' + }); + + describe('Link', () => { + it("doesn't render a prefix for the default locale", () => { + const markup = renderToString(About); + expect(markup).toContain('href="/about"'); + }); + + it('renders a prefix for a different locale', () => { + const markup = renderToString( + + Über uns + + ); + expect(markup).toContain('href="/de/ueber-uns"'); + }); + }); + + describe('redirect', () => { + function Component({ + href + }: { + href: Parameters>[0]; + }) { + redirect(href); + return null; + } + + it('can redirect for the default locale', () => { + vi.mocked(useNextPathname).mockImplementation(() => '/'); + const {rerender} = render(); + expect(nextRedirect).toHaveBeenLastCalledWith('/'); + + rerender(); + expect(nextRedirect).toHaveBeenLastCalledWith('/about'); + + rerender( + + ); + expect(nextRedirect).toHaveBeenLastCalledWith('/news/launch-party-3'); + }); + + it('can redirect for a non-default locale', () => { + vi.mocked(useParams).mockImplementation(() => ({locale: 'de'})); + vi.mocked(getRequestLocale).mockImplementation(() => 'de'); + vi.mocked(useNextPathname).mockImplementation(() => '/'); + + const {rerender} = render(); + expect(nextRedirect).toHaveBeenLastCalledWith('/'); + + rerender(); + expect(nextRedirect).toHaveBeenLastCalledWith('/ueber-uns'); + + rerender( + + ); + expect(nextRedirect).toHaveBeenLastCalledWith( + '/neuigkeiten/launch-party-3' + ); + }); + + it('supports optional search params', () => { + vi.mocked(useNextPathname).mockImplementation(() => '/'); + render( + + ); + expect(nextRedirect).toHaveBeenLastCalledWith( + '/?foo=bar&bar=1&bar=2' + ); + }); + + it('handles unknown routes', () => { + vi.mocked(useNextPathname).mockImplementation(() => '/'); + // @ts-expect-error -- Unknown route + render(); + expect(nextRedirect).toHaveBeenLastCalledWith('/unknown'); + }); + }); + }); + } +); diff --git a/packages/next-intl/test/navigation/createSharedPathnamesNavigation.test.tsx b/packages/next-intl/test/navigation/createSharedPathnamesNavigation.test.tsx new file mode 100644 index 000000000..45b5953de --- /dev/null +++ b/packages/next-intl/test/navigation/createSharedPathnamesNavigation.test.tsx @@ -0,0 +1,230 @@ +import {render, screen} from '@testing-library/react'; +import { + usePathname as useNextPathname, + useParams, + redirect as nextRedirect +} from 'next/navigation'; +import React from 'react'; +import {renderToString} from 'react-dom/server'; +import {it, describe, vi, expect, beforeEach} from 'vitest'; +import createSharedPathnamesNavigationClient from '../../src/navigation/react-client/createSharedPathnamesNavigation'; +import createSharedPathnamesNavigationServer from '../../src/navigation/react-server/createSharedPathnamesNavigation'; +import BaseLink from '../../src/navigation/shared/BaseLink'; +import {getRequestLocale} from '../../src/server/RequestLocale'; + +vi.mock('next/navigation', () => ({ + useParams: vi.fn(() => ({locale: 'en'})), + usePathname: vi.fn(() => '/'), + redirect: vi.fn() +})); +vi.mock('next-intl/config', () => ({ + default: async () => + ((await vi.importActual('../../src/server')) as any).getRequestConfig({ + locale: 'en' + }) +})); +vi.mock('react', async (importOriginal) => ({ + ...((await importOriginal()) as typeof import('react')), + cache(fn: (...args: Array) => unknown) { + return (...args: Array) => fn(...args); + } +})); +// Avoids handling an async component (not supported by renderToString) +vi.mock('../../src/navigation/react-server/ServerLink', () => ({ + default({locale, ...rest}: any) { + return ; + } +})); +vi.mock('../../src/server/RequestLocale', () => ({ + getRequestLocale: vi.fn(() => 'en') +})); + +beforeEach(() => { + vi.mocked(getRequestLocale).mockImplementation(() => 'en'); + vi.mocked(useParams).mockImplementation(() => ({locale: 'en'})); +}); + +const locales = ['en', 'de'] as const; + +describe.each([ + {env: 'react-client', implementation: createSharedPathnamesNavigationClient}, + {env: 'react-server', implementation: createSharedPathnamesNavigationServer} +])( + 'createSharedPathnamesNavigation ($env)', + ({implementation: createSharedPathnamesNavigation}) => { + describe("localePrefix: 'always'", () => { + const {Link} = createSharedPathnamesNavigation({ + locales, + localePrefix: 'always' + }); + describe('Link', () => { + it('renders a prefix for the default locale', () => { + const markup = renderToString(About); + expect(markup).toContain('href="/en/about"'); + }); + + it('renders a prefix for a different locale', () => { + const markup = renderToString( + + Über uns + + ); + expect(markup).toContain('href="/de/about"'); + }); + + it('renders an object href', () => { + render( + About + ); + expect( + screen.getByRole('link', {name: 'About'}).getAttribute('href') + ).toBe('/about?foo=bar'); + }); + + it('handles params', () => { + render( + + About + + ); + expect( + screen.getByRole('link', {name: 'About'}).getAttribute('href') + ).toBe('/de/news/launch-party-3'); + }); + }); + }); + + describe("localePrefix: 'as-needed'", () => { + const {Link, redirect} = createSharedPathnamesNavigation({ + locales, + localePrefix: 'as-needed' + }); + + describe('Link', () => { + it('renders a prefix for the default locale initially', () => { + const markup = renderToString(About); + expect(markup).toContain('href="/en/about"'); + }); + + it("doesn't render a prefix for the default locale eventually", () => { + render(Über uns); + expect(screen.getByText('Über uns').getAttribute('href')).toBe( + '/about' + ); + }); + + it('renders a prefix for a different locale', () => { + const markup = renderToString( + + Über uns + + ); + expect(markup).toContain('href="/de/about"'); + }); + }); + + describe('redirect', () => { + function Component({href}: {href: string}) { + redirect(href); + return null; + } + + it('can redirect for the default locale', () => { + vi.mocked(useNextPathname).mockImplementation(() => '/'); + const {rerender} = render(); + expect(nextRedirect).toHaveBeenLastCalledWith('/en'); + + rerender(); + expect(nextRedirect).toHaveBeenLastCalledWith('/en/about'); + + rerender(); + expect(nextRedirect).toHaveBeenLastCalledWith( + '/en/news/launch-party-3' + ); + }); + + it('can redirect for a non-default locale', () => { + vi.mocked(useParams).mockImplementation(() => ({locale: 'de'})); + vi.mocked(getRequestLocale).mockImplementation(() => 'de'); + + vi.mocked(useNextPathname).mockImplementation(() => '/'); + const {rerender} = render(); + expect(nextRedirect).toHaveBeenLastCalledWith('/de'); + + rerender(); + expect(nextRedirect).toHaveBeenLastCalledWith('/de/about'); + + rerender(); + expect(nextRedirect).toHaveBeenLastCalledWith( + '/de/news/launch-party-3' + ); + }); + + it('supports optional search params', () => { + vi.mocked(useNextPathname).mockImplementation(() => '/'); + render(); + expect(nextRedirect).toHaveBeenLastCalledWith( + '/en?foo=bar&bar=1&bar=2' + ); + }); + }); + }); + + describe("localePrefix: 'never'", () => { + const {Link, redirect} = createSharedPathnamesNavigation({ + locales, + localePrefix: 'never' + }); + + describe('Link', () => { + it("doesn't render a prefix for the default locale", () => { + const markup = renderToString(About); + expect(markup).toContain('href="/about"'); + }); + + it('renders a prefix for a different locale', () => { + const markup = renderToString( + + Über uns + + ); + expect(markup).toContain('href="/de/about"'); + }); + }); + + describe('redirect', () => { + function Component({href}: {href: string}) { + redirect(href); + return null; + } + + it('can redirect for the default locale', () => { + vi.mocked(useNextPathname).mockImplementation(() => '/'); + const {rerender} = render(); + expect(nextRedirect).toHaveBeenLastCalledWith('/'); + + rerender(); + expect(nextRedirect).toHaveBeenLastCalledWith('/about'); + + rerender(); + expect(nextRedirect).toHaveBeenLastCalledWith('/news/launch-party-3'); + }); + + it('can redirect for a non-default locale', () => { + vi.mocked(useParams).mockImplementation(() => ({locale: 'de'})); + vi.mocked(getRequestLocale).mockImplementation(() => 'de'); + + vi.mocked(useNextPathname).mockImplementation(() => '/'); + const {rerender} = render(); + expect(nextRedirect).toHaveBeenLastCalledWith('/'); + + rerender(); + expect(nextRedirect).toHaveBeenLastCalledWith('/about'); + + rerender(); + expect(nextRedirect).toHaveBeenLastCalledWith('/news/launch-party-3'); + }); + }); + }); + } +); diff --git a/packages/next-intl/test/navigation/react-client/BaseLink.test.tsx b/packages/next-intl/test/navigation/react-client/ClientLink.test.tsx similarity index 79% rename from packages/next-intl/test/navigation/react-client/BaseLink.test.tsx rename to packages/next-intl/test/navigation/react-client/ClientLink.test.tsx index 450591fff..2d2a6f6ab 100644 --- a/packages/next-intl/test/navigation/react-client/BaseLink.test.tsx +++ b/packages/next-intl/test/navigation/react-client/ClientLink.test.tsx @@ -3,7 +3,7 @@ import {usePathname, useParams} from 'next/navigation'; import React from 'react'; import {it, describe, vi, beforeEach, expect} from 'vitest'; import {NextIntlClientProvider} from '../../../src/index.react-client'; -import BaseLink from '../../../src/navigation/react-client/BaseLink'; +import ClientLink from '../../../src/navigation/react-client/ClientLink'; vi.mock('next/navigation'); @@ -14,7 +14,7 @@ describe('unprefixed routing', () => { }); it('renders an href without a locale if the locale matches', () => { - render(Test); + render(Test); expect(screen.getByRole('link', {name: 'Test'}).getAttribute('href')).toBe( '/test' ); @@ -22,7 +22,9 @@ describe('unprefixed routing', () => { it('renders an href without a locale if the locale matches for an object href', () => { render( - Test + + Test + ); expect(screen.getByRole('link', {name: 'Test'}).getAttribute('href')).toBe( '/test?foo=bar' @@ -31,9 +33,9 @@ describe('unprefixed routing', () => { it('renders an href with a locale if the locale changes', () => { render( - + Test - + ); expect(screen.getByRole('link', {name: 'Test'}).getAttribute('href')).toBe( '/de/test' @@ -42,9 +44,9 @@ describe('unprefixed routing', () => { it('renders an href with a locale if the locale changes for an object href', () => { render( - + Test - + ); expect(screen.getByRole('link', {name: 'Test'}).getAttribute('href')).toBe( '/de/test' @@ -52,7 +54,7 @@ describe('unprefixed routing', () => { }); it('works for external urls', () => { - render(Test); + render(Test); expect(screen.getByRole('link', {name: 'Test'}).getAttribute('href')).toBe( 'https://example.com' ); @@ -60,7 +62,7 @@ describe('unprefixed routing', () => { it('works for external urls with an object href', () => { render( - { }} > Test - + ); expect(screen.getByRole('link', {name: 'Test'}).getAttribute('href')).toBe( 'https://example.com/test' @@ -79,21 +81,21 @@ describe('unprefixed routing', () => { let ref; render( - { ref = node; }} href="/test" > Test - + ); expect(ref).toBeDefined(); }); it('sets an hreflang', () => { - render(Test); + render(Test); expect( screen.getByRole('link', {name: 'Test'}).getAttribute('hreflang') ).toBe('en'); @@ -107,14 +109,14 @@ describe('prefixed routing', () => { }); it('renders an href with a locale if the locale matches', () => { - render(Test); + render(Test); expect(screen.getByRole('link', {name: 'Test'}).getAttribute('href')).toBe( '/en/test' ); }); it('renders an href without a locale if the locale matches for an object href', () => { - render(Test); + render(Test); expect(screen.getByRole('link', {name: 'Test'}).getAttribute('href')).toBe( '/en/test' ); @@ -122,9 +124,9 @@ describe('prefixed routing', () => { it('renders an href with a locale if the locale changes', () => { render( - + Test - + ); expect(screen.getByRole('link', {name: 'Test'}).getAttribute('href')).toBe( '/de/test' @@ -133,9 +135,9 @@ describe('prefixed routing', () => { it('renders an href with a locale if the locale changes for an object href', () => { render( - + Test - + ); expect(screen.getByRole('link', {name: 'Test'}).getAttribute('href')).toBe( '/de/test' @@ -143,7 +145,7 @@ describe('prefixed routing', () => { }); it('works for external urls', () => { - render(Test); + render(Test); expect(screen.getByRole('link', {name: 'Test'}).getAttribute('href')).toBe( 'https://example.com' ); @@ -151,7 +153,7 @@ describe('prefixed routing', () => { it('works for external urls with an object href', () => { render( - { }} > Test - + ); expect(screen.getByRole('link', {name: 'Test'}).getAttribute('href')).toBe( 'https://example.com/test' @@ -175,7 +177,7 @@ describe('usage outside of Next.js', () => { it('works with a provider', () => { render( - Test + Test ); expect(screen.getByRole('link', {name: 'Test'}).getAttribute('href')).toBe( @@ -184,7 +186,7 @@ describe('usage outside of Next.js', () => { }); it('throws without a provider', () => { - expect(() => render(Test)).toThrow( + expect(() => render(Test)).toThrow( 'No intl context found. Have you configured the provider?' ); }); diff --git a/packages/next-intl/test/navigation/react-client/createLocalizedPathnamesNavigation.test.tsx b/packages/next-intl/test/navigation/react-client/createLocalizedPathnamesNavigation.test.tsx index bd57d327e..8b727bfb3 100644 --- a/packages/next-intl/test/navigation/react-client/createLocalizedPathnamesNavigation.test.tsx +++ b/packages/next-intl/test/navigation/react-client/createLocalizedPathnamesNavigation.test.tsx @@ -2,8 +2,7 @@ import {render, screen} from '@testing-library/react'; import { usePathname as useNextPathname, useParams, - useRouter as useNextRouter, - redirect as nextRedirect + useRouter as useNextRouter } from 'next/navigation'; import React, {ComponentProps} from 'react'; import {it, describe, vi, beforeEach, expect, Mock} from 'vitest'; @@ -32,12 +31,6 @@ const pathnames = { '/catch-all/[[...parts]]': '/catch-all/[[...parts]]' } satisfies Pathnames; -const {Link, getPathname, redirect, usePathname, useRouter} = - createLocalizedPathnamesNavigation({ - locales, - pathnames - }); - beforeEach(() => { const router = { push: vi.fn(), @@ -56,483 +49,318 @@ beforeEach(() => { vi.mocked(useParams).mockImplementation(() => ({locale: 'en'})); }); -describe('redirect', () => { - it('can redirect for the default locale', () => { - function Component({ - href - }: { - href: Parameters>[0]; - }) { - redirect(href); - return null; - } - - vi.mocked(useNextPathname).mockImplementation(() => '/'); - const {rerender} = render(); - expect(nextRedirect).toHaveBeenLastCalledWith('/en'); - - rerender(); - expect(nextRedirect).toHaveBeenLastCalledWith('/en/about'); - - rerender( - - ); - expect(nextRedirect).toHaveBeenLastCalledWith('/en/news/launch-party-3'); - }); - - it('can redirect for a non-default locale', () => { - vi.mocked(useParams).mockImplementation(() => ({locale: 'de'})); - function Component({ - href - }: { - href: Parameters>[0]; - }) { - redirect(href); - return null; - } - vi.mocked(useNextPathname).mockImplementation(() => '/'); - const {rerender} = render(); - expect(nextRedirect).toHaveBeenLastCalledWith('/de'); - - rerender(); - expect(nextRedirect).toHaveBeenLastCalledWith('/de/ueber-uns'); - - rerender( - - ); - expect(nextRedirect).toHaveBeenLastCalledWith( - '/de/neuigkeiten/launch-party-3' - ); - }); - - it('supports optional search params', () => { - function Component({ - href - }: { - href: Parameters>[0]; - }) { - redirect(href); - return null; - } - - vi.mocked(useNextPathname).mockImplementation(() => '/'); - render( - - ); - expect(nextRedirect).toHaveBeenLastCalledWith('/en?foo=bar&bar=1&bar=2'); - }); - - it('handles unknown route', () => { - function Component({ - href - }: { - href: Parameters>[0]; - }) { - redirect(href); - return null; - } - - vi.mocked(useNextPathname).mockImplementation(() => '/'); - // @ts-expect-error -- Unknown route - render(); - expect(nextRedirect).toHaveBeenLastCalledWith('/en/unknown'); - }); -}); - -describe('usePathname', () => { - it('returns the internal pathname for the default locale', () => { - function Component() { - const pathname = usePathname(); - return <>{pathname}; - } - vi.mocked(useNextPathname).mockImplementation(() => '/'); - const {rerender} = render(); - screen.getByText('/'); - - vi.mocked(useNextPathname).mockImplementation(() => '/about'); - rerender(); - screen.getByText('/about'); - - vi.mocked(useNextPathname).mockImplementation(() => '/news/launch-party-3'); - rerender(); - screen.getByText('/news/[articleSlug]-[articleId]'); - }); - - it('returns the internal pathname a non-default locale', () => { - vi.mocked(useParams).mockImplementation(() => ({locale: 'de'})); - - function Component() { - const pathname = usePathname(); - return <>{pathname}; - } - vi.mocked(useNextPathname).mockImplementation(() => '/de'); - const {rerender} = render(); - screen.getByText('/'); - - vi.mocked(useNextPathname).mockImplementation(() => '/de/ueber-uns'); - rerender(); - screen.getByText('/about'); - - vi.mocked(useNextPathname).mockImplementation( - () => '/de/neuigkeiten/launch-party-3' - ); - rerender(); - screen.getByText('/news/[articleSlug]-[articleId]'); - }); - - it('handles unknown route', () => { - function Component() { - const pathname = usePathname(); - return <>{pathname}; - } - vi.mocked(useNextPathname).mockImplementation(() => '/en/unknown'); - const {rerender} = render(); - screen.getByText('/unknown'); - - vi.mocked(useNextPathname).mockImplementation(() => '/de/unknown'); - rerender(); - screen.getByText('/de/unknown'); - }); -}); - -describe('Link', () => { - it('renders an href', () => { - render(About); - expect(screen.getByRole('link', {name: 'About'}).getAttribute('href')).toBe( - '/about' - ); - }); - - it('adds a prefix when linking to a non-default locale', () => { - render( - - Über uns - - ); - expect( - screen.getByRole('link', {name: 'Über uns'}).getAttribute('href') - ).toBe('/de/ueber-uns'); - }); - - it('handles params', () => { - render( - - About - - ); - expect(screen.getByRole('link', {name: 'About'}).getAttribute('href')).toBe( - '/de/neuigkeiten/launch-party-3' - ); - }); - - it('handles catch-all segments', () => { - render( - - Test - - ); - expect(screen.getByRole('link', {name: 'Test'}).getAttribute('href')).toBe( - '/categories/clothing/t-shirts' - ); - }); - - it('handles optional catch-all segments', () => { - render( - - Test - - ); - expect(screen.getByRole('link', {name: 'Test'}).getAttribute('href')).toBe( - '/catch-all/one/two' - ); - }); +describe("localePrefix: 'as-needed'", () => { + const {Link, redirect, usePathname, useRouter} = + createLocalizedPathnamesNavigation({ + locales, + pathnames + }); - it('supports optional search params', () => { - render( - - Test - - ); - expect(screen.getByRole('link', {name: 'Test'}).getAttribute('href')).toBe( - '/about?foo=bar&bar=1&bar=2' - ); + describe('Link', () => { + it('supports receiving a ref', () => { + const ref = React.createRef(); + render(); + expect(ref.current).not.toBe(null); + }); }); - it('handles unknown routes', () => { - // @ts-expect-error -- Unknown route - const {rerender} = render(Unknown); - expect( - screen.getByRole('link', {name: 'Unknown'}).getAttribute('href') - ).toBe('/unknown'); - - rerender( - // @ts-expect-error -- Unknown route - - Unknown - - ); - expect( - screen.getByRole('link', {name: 'Unknown'}).getAttribute('href') - ).toBe('/de/unknown'); - }); -}); + describe('usePathname', () => { + it('returns the internal pathname for the default locale', () => { + function Component() { + const pathname = usePathname(); + return <>{pathname}; + } + vi.mocked(useNextPathname).mockImplementation(() => '/'); + const {rerender} = render(); + screen.getByText('/'); + + vi.mocked(useNextPathname).mockImplementation(() => '/about'); + rerender(); + screen.getByText('/about'); + + vi.mocked(useNextPathname).mockImplementation( + () => '/news/launch-party-3' + ); + rerender(); + screen.getByText('/news/[articleSlug]-[articleId]'); + }); -describe('getPathname', () => { - it('resolves to the correct path', () => { - expect( - getPathname({ - locale: 'en', - href: { - pathname: '/categories/[...parts]', - params: {parts: ['clothing', 't-shirts']}, - query: {sort: 'price'} - } - }) - ).toBe('/categories/clothing/t-shirts?sort=price'); - }); -}); + it('returns the internal pathname a non-default locale', () => { + vi.mocked(useParams).mockImplementation(() => ({locale: 'de'})); -describe('useRouter', () => { - describe('push', () => { - it('resolves to the correct path when passing another locale', () => { function Component() { - const router = useRouter(); - router.push('/about', {locale: 'de'}); - return null; + const pathname = usePathname(); + return <>{pathname}; } - render(); - const push = useNextRouter().push as Mock; - expect(push).toHaveBeenCalledTimes(1); - expect(push).toHaveBeenCalledWith('/de/ueber-uns'); + vi.mocked(useNextPathname).mockImplementation(() => '/de'); + const {rerender} = render(); + screen.getByText('/'); + + vi.mocked(useNextPathname).mockImplementation(() => '/de/ueber-uns'); + rerender(); + screen.getByText('/about'); + + vi.mocked(useNextPathname).mockImplementation( + () => '/de/neuigkeiten/launch-party-3' + ); + rerender(); + screen.getByText('/news/[articleSlug]-[articleId]'); }); - it('supports optional search params', () => { + it('handles unknown routes', () => { function Component() { - const router = useRouter(); - router.push( - { - pathname: '/about', - query: { - foo: 'bar', - bar: [1, 2] - } - }, - {locale: 'de'} - ); - return null; + const pathname = usePathname(); + return <>{pathname}; } - render(); - const push = useNextRouter().push as Mock; - expect(push).toHaveBeenCalledTimes(1); - expect(push).toHaveBeenCalledWith('/de/ueber-uns?foo=bar&bar=1&bar=2'); + vi.mocked(useNextPathname).mockImplementation(() => '/en/unknown'); + const {rerender} = render(); + screen.getByText('/unknown'); + + vi.mocked(useNextPathname).mockImplementation(() => '/de/unknown'); + rerender(); + screen.getByText('/de/unknown'); + }); + }); + + describe('useRouter', () => { + describe('push', () => { + it('resolves to the correct path when passing another locale', () => { + function Component() { + const router = useRouter(); + router.push('/about', {locale: 'de'}); + return null; + } + render(); + const push = useNextRouter().push as Mock; + expect(push).toHaveBeenCalledTimes(1); + expect(push).toHaveBeenCalledWith('/de/ueber-uns'); + }); + + it('supports optional search params', () => { + function Component() { + const router = useRouter(); + router.push( + { + pathname: '/about', + query: { + foo: 'bar', + bar: [1, 2] + } + }, + {locale: 'de'} + ); + return null; + } + render(); + const push = useNextRouter().push as Mock; + expect(push).toHaveBeenCalledTimes(1); + expect(push).toHaveBeenCalledWith('/de/ueber-uns?foo=bar&bar=1&bar=2'); + }); + + it('passes through unknown options to the Next.js router', () => { + function Component() { + const router = useRouter(); + // @ts-expect-error -- Wait for https://github.com/vercel/next.js/pull/59001 + router.push('/about', {locale: 'de', scroll: false}); + return null; + } + render(); + const push = useNextRouter().push as Mock; + expect(push).toHaveBeenCalledTimes(1); + expect(push).toHaveBeenCalledWith('/de/ueber-uns', {scroll: false}); + }); }); - it('passes through unknown options to the Next.js router', () => { + it('handles unknown routes', () => { function Component() { const router = useRouter(); - // @ts-expect-error -- Wait for https://github.com/vercel/next.js/pull/59001 - router.push('/about', {locale: 'de', scroll: false}); + // @ts-expect-error -- Unknown route + router.push('/unknown'); return null; } render(); const push = useNextRouter().push as Mock; expect(push).toHaveBeenCalledTimes(1); - expect(push).toHaveBeenCalledWith('/de/ueber-uns', {scroll: false}); + expect(push).toHaveBeenCalledWith('/unknown'); }); }); - it('handles unknown routes', () => { - function Component() { - const router = useRouter(); - // @ts-expect-error -- Unknown route - router.push('/unknown'); - return null; - } - render(); - const push = useNextRouter().push as Mock; - expect(push).toHaveBeenCalledTimes(1); - expect(push).toHaveBeenCalledWith('/unknown'); - }); -}); - -/** - * Type tests - */ - -// eslint-disable-next-line @typescript-eslint/no-unused-vars -function TypeTests() { - const router = useRouter(); - - // @ts-expect-error -- Unknown route - router.push('/unknown'); - - // Valid - router.push('/about'); - router.push('/about', {locale: 'de'}); - router.push({pathname: '/about'}); - router.push('/catch-all/[[...parts]]'); - - // @ts-expect-error -- Requires params - router.push({pathname: '/news/[articleSlug]-[articleId]'}); + /** + * Type tests + */ - router.push({ - pathname: '/news/[articleSlug]-[articleId]', - // @ts-expect-error -- Missing param - params: { - articleId: 3 - } - }); + // eslint-disable-next-line @typescript-eslint/no-unused-vars + function TypeTests() { + const router = useRouter(); - // Valid - router.push({ - pathname: '/news/[articleSlug]-[articleId]', - params: { - articleId: 3, - articleSlug: 'launch-party' - } - }); + // @ts-expect-error -- Unknown route + router.push('/unknown'); - // @ts-expect-error -- Doesn't accept params - router.push({pathname: '/about', params: {foo: 'bar'}}); + // Valid + router.push('/about'); + router.push('/about', {locale: 'de'}); + router.push({pathname: '/about'}); + router.push('/catch-all/[[...parts]]'); - // @ts-expect-error -- Unknown locale - - Über uns - ; + // @ts-expect-error -- Requires params + router.push({pathname: '/news/[articleSlug]-[articleId]'}); - // @ts-expect-error -- Unknown route - About; + router.push({ + pathname: '/news/[articleSlug]-[articleId]', + // @ts-expect-error -- Missing param + params: { + articleId: 3 + } + }); - // @ts-expect-error -- Requires params - About; - // @ts-expect-error -- Requires params - About; + // Valid + router.push({ + pathname: '/news/[articleSlug]-[articleId]', + params: { + articleId: 3, + articleSlug: 'launch-party' + } + }); - // @ts-expect-error -- Params for different route - About; + // @ts-expect-error -- Doesn't accept params + router.push({pathname: '/about', params: {foo: 'bar'}}); - // @ts-expect-error -- Doesn't accept params - About; + // @ts-expect-error -- Unknown locale + + Über uns + ; - // @ts-expect-error -- Missing params - Über uns; + // @ts-expect-error -- Unknown route + About; + + // @ts-expect-error -- Requires params + About; + // @ts-expect-error -- Requires params + About; + + // @ts-expect-error -- Params for different route + About; + + // @ts-expect-error -- Doesn't accept params + About; + + // @ts-expect-error -- Missing params + Über uns; + + // Valid + Über uns; + Über uns; + + Über uns + ; + Optional catch-all; + + // Link composition + function WrappedLink( + props: ComponentProps> + ) { + return ; + } + About; + + News + ; + + // @ts-expect-error -- Requires params + News; + + // Valid + redirect({pathname: '/about'}); + redirect('/catch-all/[[...parts]]'); + redirect({ + pathname: '/catch-all/[[...parts]]', + params: {parts: ['one', 'two']} + }); - // Valid - Über uns; - Über uns; - - Über uns - ; - Optional catch-all; - - // Link composition - function WrappedLink( - props: ComponentProps> - ) { - return ; + articleId: 3 + } + }); + + // Allow unknown routes + const { + Link: LinkWithUnknown, + redirect: redirectWithUnknown, + usePathname: usePathnameWithUnkown, + useRouter: useRouterWithUnknown + } = createLocalizedPathnamesNavigation({ + locales, + // eslint-disable-next-line @typescript-eslint/ban-types + pathnames: pathnames as typeof pathnames & Record + }); + Unknown; + redirectWithUnknown('/unknown'); + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const pathnameWithUnknown: ReturnType = + '/unknown'; + useRouterWithUnknown().push('/unknown'); } - About; - - News - ; - - // @ts-expect-error -- Requires params - News; - - // Valid - redirect({pathname: '/about'}); - redirect('/catch-all/[[...parts]]'); - redirect({ - pathname: '/catch-all/[[...parts]]', - params: {parts: ['one', 'two']} +}); + +describe("localePrefix: 'never'", () => { + const {useRouter} = createLocalizedPathnamesNavigation({ + pathnames, + locales, + localePrefix: 'never' }); - // @ts-expect-error -- Unknown route - redirect('/unknown'); - // @ts-expect-error -- Localized alternative - redirect('/ueber-uns'); - // @ts-expect-error -- Requires params - redirect('/news/[articleSlug]-[articleId]'); - redirect({ - pathname: '/news/[articleSlug]-[articleId]', - // @ts-expect-error -- Missing param - params: { - articleId: 3 + describe('useRouter', () => { + function Component({locale}: {locale?: string}) { + const router = useRouter(); + router.push('/about', {locale}); + return null; } - }); - // Allow unknown routes - const { - Link: LinkWithUnknown, - redirect: redirectWithUnknown, - usePathname: usePathnameWithUnkown, - useRouter: useRouterWithUnknown - } = createLocalizedPathnamesNavigation({ - locales, - // eslint-disable-next-line @typescript-eslint/ban-types - pathnames: pathnames as typeof pathnames & Record + describe('push', () => { + it('can push a pathname for the default locale', () => { + render(); + const push = useNextRouter().push as Mock; + expect(push).toHaveBeenCalledTimes(1); + expect(push).toHaveBeenCalledWith('/about'); + }); + + it('can push a pathname for a secondary locale', () => { + vi.mocked(useParams).mockImplementation(() => ({locale: 'de'})); + render(); + const push = useNextRouter().push as Mock; + expect(push).toHaveBeenCalledTimes(1); + expect(push).toHaveBeenCalledWith('/ueber-uns'); + }); + + it('resolves to the correct path when passing another locale', () => { + render(); + const push = useNextRouter().push as Mock; + expect(push).toHaveBeenCalledTimes(1); + expect(push).toHaveBeenCalledWith('/de/ueber-uns'); + }); + }); }); - Unknown; - redirectWithUnknown('/unknown'); - // eslint-disable-next-line @typescript-eslint/no-unused-vars - const pathnameWithUnknown: ReturnType = - '/unknown'; - useRouterWithUnknown().push('/unknown'); -} +}); diff --git a/packages/next-intl/test/navigation/react-client/createSharedPathnamesNavigation.test.tsx b/packages/next-intl/test/navigation/react-client/createSharedPathnamesNavigation.test.tsx index 369f3e765..b5b1d8e84 100644 --- a/packages/next-intl/test/navigation/react-client/createSharedPathnamesNavigation.test.tsx +++ b/packages/next-intl/test/navigation/react-client/createSharedPathnamesNavigation.test.tsx @@ -1,4 +1,4 @@ -import {render, screen} from '@testing-library/react'; +import {render} from '@testing-library/react'; import { usePathname, useParams, @@ -10,9 +10,7 @@ import createSharedPathnamesNavigation from '../../../src/navigation/react-clien vi.mock('next/navigation'); -const {Link, useRouter} = createSharedPathnamesNavigation({ - locales: ['en', 'de'] as const -}); +const locales = ['en', 'de'] as const; beforeEach(() => { const router = { @@ -28,100 +26,116 @@ beforeEach(() => { vi.mocked(useParams).mockImplementation(() => ({locale: 'en'})); }); -describe('Link', () => { - it('renders an href', () => { - render(About); - expect(screen.getByRole('link', {name: 'About'}).getAttribute('href')).toBe( - '/about' - ); +describe("localePrefix: 'as-needed'", () => { + const {Link, useRouter} = createSharedPathnamesNavigation({ + locales, + localePrefix: 'as-needed' }); - it('renders an object href', () => { - render(About); - expect(screen.getByRole('link', {name: 'About'}).getAttribute('href')).toBe( - '/about?foo=bar' - ); + describe('Link', () => { + it('supports receiving a ref', () => { + const ref = React.createRef(); + render(); + expect(ref.current).not.toBe(null); + }); }); - it('adds a prefix when linking to a non-default locale', () => { - render( - - Über uns - - ); - expect( - screen.getByRole('link', {name: 'Über uns'}).getAttribute('href') - ).toBe('/de/about'); + describe('useRouter', () => { + describe('push', () => { + it('resolves to the correct path when passing another locale', () => { + function Component() { + const router = useRouter(); + router.push('/about', {locale: 'de'}); + return null; + } + render(); + const push = useNextRouter().push as Mock; + expect(push).toHaveBeenCalledTimes(1); + expect(push).toHaveBeenCalledWith('/de/about'); + }); + + it('passes through unknown options to the Next.js router', () => { + function Component() { + const router = useRouter(); + // @ts-expect-error -- Wait for https://github.com/vercel/next.js/pull/59001 + router.push('/about', {locale: 'de', scroll: false}); + return null; + } + render(); + const push = useNextRouter().push as Mock; + expect(push).toHaveBeenCalledTimes(1); + expect(push).toHaveBeenCalledWith('/de/about', {scroll: false}); + }); + }); }); - it('handles params', () => { - render( - - About - - ); - expect(screen.getByRole('link', {name: 'About'}).getAttribute('href')).toBe( - '/de/news/launch-party-3' - ); - }); -}); + /** + * Type tests + */ -describe('useRouter', () => { - describe('push', () => { - it('resolves to the correct path when passing another locale', () => { - function Component() { - const router = useRouter(); - router.push('/about', {locale: 'de'}); - return null; - } - render(); - const push = useNextRouter().push as Mock; - expect(push).toHaveBeenCalledTimes(1); - expect(push).toHaveBeenCalledWith('/de/about'); - }); + // eslint-disable-next-line @typescript-eslint/no-unused-vars + function TypeTests() { + const router = useRouter(); - it('passes through unknown options to the Next.js router', () => { - function Component() { - const router = useRouter(); - // @ts-expect-error -- Wait for https://github.com/vercel/next.js/pull/59001 - router.push('/about', {locale: 'de', scroll: false}); - return null; - } - render(); - const push = useNextRouter().push as Mock; - expect(push).toHaveBeenCalledTimes(1); - expect(push).toHaveBeenCalledWith('/de/about', {scroll: false}); - }); - }); -}); - -/** - * Type tests - */ + // @ts-expect-error -- Only supports string paths + router.push({pathname: '/about'}); -// eslint-disable-next-line @typescript-eslint/no-unused-vars -function TypeTests() { - const router = useRouter(); + // Valid + router.push('/about'); + router.push('/about', {locale: 'de'}); + router.push('/unknown'); // No error since routes are unknown - // @ts-expect-error -- Only supports string paths - router.push({pathname: '/about'}); + // @ts-expect-error -- No params supported + + User + ; - // Valid - router.push('/about'); - router.push('/about', {locale: 'de'}); - router.push('/unknown'); // No error since routes are unknown + // @ts-expect-error -- Unknown locale + + User + ; - // @ts-expect-error -- No params supported - - User - ; + // Valid + Über uns; + About; // No error since routes are unknown + } +}); - // @ts-expect-error -- Unknown locale - - User - ; +describe("localePrefix: 'never'", () => { + const {useRouter} = createSharedPathnamesNavigation({ + locales, + localePrefix: 'never' + }); - // Valid - Über uns; - About; // No error since routes are unknown -} + describe('useRouter', () => { + function Component({locale}: {locale?: string}) { + const router = useRouter(); + router.push('/about', {locale}); + return null; + } + + describe('push', () => { + it('can push a pathname for the default locale', () => { + render(); + const push = useNextRouter().push as Mock; + expect(push).toHaveBeenCalledTimes(1); + expect(push).toHaveBeenCalledWith('/about'); + }); + + it('can push a pathname for a secondary locale', () => { + vi.mocked(useParams).mockImplementation(() => ({locale: 'de'})); + render(); + const push = useNextRouter().push as Mock; + expect(push).toHaveBeenCalledTimes(1); + expect(push).toHaveBeenCalledWith('/about'); + }); + + it('resolves to the correct path when passing another locale', () => { + render(); + const push = useNextRouter().push as Mock; + expect(push).toHaveBeenCalledTimes(1); + expect(push).toHaveBeenCalledWith('/de/about'); + }); + }); + }); +}); diff --git a/packages/next-intl/test/shared/utils.test.tsx b/packages/next-intl/test/shared/utils.test.tsx index 7d84bd415..3d398f150 100644 --- a/packages/next-intl/test/shared/utils.test.tsx +++ b/packages/next-intl/test/shared/utils.test.tsx @@ -3,20 +3,20 @@ import { hasPathnamePrefixed, unlocalizePathname, matchesPathname, - localizePathname + prefixPathname } from '../../src/shared/utils'; -describe('localizePathname', () => { +describe('prefixPathname', () => { it("doesn't add trailing slashes for the root", () => { - expect(localizePathname('en', '/')).toEqual('/en'); + expect(prefixPathname('en', '/')).toEqual('/en'); }); it("doesn't add trailing slashes for search params", () => { - expect(localizePathname('en', '/?foo=bar')).toEqual('/en?foo=bar'); + expect(prefixPathname('en', '/?foo=bar')).toEqual('/en?foo=bar'); }); it('localizes nested paths', () => { - expect(localizePathname('en', '/nested')).toEqual('/en/nested'); + expect(prefixPathname('en', '/nested')).toEqual('/en/nested'); }); }); diff --git a/packages/use-intl/src/core/utils/NestedValueOf.tsx b/packages/use-intl/src/core/utils/NestedValueOf.tsx index c048df56f..4d396f4a3 100644 --- a/packages/use-intl/src/core/utils/NestedValueOf.tsx +++ b/packages/use-intl/src/core/utils/NestedValueOf.tsx @@ -6,7 +6,7 @@ type NestedValueOf< ? NestedValueOf : never : Property extends keyof ObjectType - ? ObjectType[Property] - : never; + ? ObjectType[Property] + : never; export default NestedValueOf; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0189f22c4..3441b1565 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -519,6 +519,9 @@ importers: '@types/react': specifier: 18.2.34 version: 18.2.34 + '@types/react-dom': + specifier: ^18.2.17 + version: 18.2.17 eslint: specifier: ^8.54.0 version: 8.54.0 @@ -7459,7 +7462,7 @@ packages: dependencies: '@babel/runtime': 7.21.5 '@testing-library/dom': 8.20.0 - '@types/react-dom': 18.2.14 + '@types/react-dom': 18.2.17 react: 18.2.0 react-dom: 18.2.0(react@18.2.0) dev: true @@ -7760,6 +7763,12 @@ packages: '@types/react': 18.2.34 dev: true + /@types/react-dom@18.2.17: + resolution: {integrity: sha512-rvrT/M7Df5eykWFxn6MYt5Pem/Dbyc1N8Y0S9Mrkw2WFCRiqUgw9P7ul2NpwsXCSM1DVdENzdG9J5SreqfAIWg==} + dependencies: + '@types/react': 18.2.34 + dev: true + /@types/react-dom@18.2.2: resolution: {integrity: sha512-IGuuCsLmAH0f3KksOZp/vkpUtO2YrIwob4YxvoFQR2XvkLL7tf7mLYcXiyG47KgTKngI4+7lNm4dM4eBTbG1Bw==} dependencies: @@ -12454,13 +12463,13 @@ packages: eslint-plugin-import: 2.27.5(@typescript-eslint/parser@6.4.1)(eslint@8.54.0) eslint-plugin-jest: 27.2.3(@typescript-eslint/eslint-plugin@6.4.1)(eslint@8.54.0)(jest@29.5.0)(typescript@5.2.2) eslint-plugin-jsx-a11y: 6.7.1(eslint@8.54.0) - eslint-plugin-prettier: 5.0.0(eslint@8.54.0)(prettier@3.0.2) + eslint-plugin-prettier: 5.0.0(eslint@8.54.0)(prettier@3.1.0) eslint-plugin-react: 7.33.2(eslint@8.54.0) eslint-plugin-react-hooks: 4.6.0(eslint@8.54.0) eslint-plugin-sort-destructure-keys: 1.5.0(eslint@8.54.0) eslint-plugin-tailwindcss: 3.13.0(tailwindcss@3.3.3) eslint-plugin-unicorn: 48.0.1(eslint@8.54.0) - prettier: 3.0.2 + prettier: 3.1.0 transitivePeerDependencies: - '@types/eslint' - eslint-config-prettier @@ -12806,26 +12815,6 @@ packages: semver: 6.3.1 dev: true - /eslint-plugin-prettier@5.0.0(eslint@8.54.0)(prettier@3.0.2): - resolution: {integrity: sha512-AgaZCVuYDXHUGxj/ZGu1u8H8CYgDY3iG6w5kUFw4AzMVXzB7VvbKgYR4nATIN+OvUrghMbiDLeimVjVY5ilq3w==} - engines: {node: ^14.18.0 || >=16.0.0} - peerDependencies: - '@types/eslint': '>=8.0.0' - eslint: '>=8.0.0' - eslint-config-prettier: '*' - prettier: '>=3.0.0' - peerDependenciesMeta: - '@types/eslint': - optional: true - eslint-config-prettier: - optional: true - dependencies: - eslint: 8.54.0 - prettier: 3.0.2 - prettier-linter-helpers: 1.0.0 - synckit: 0.8.5 - dev: true - /eslint-plugin-prettier@5.0.0(eslint@8.54.0)(prettier@3.1.0): resolution: {integrity: sha512-AgaZCVuYDXHUGxj/ZGu1u8H8CYgDY3iG6w5kUFw4AzMVXzB7VvbKgYR4nATIN+OvUrghMbiDLeimVjVY5ilq3w==} engines: {node: ^14.18.0 || >=16.0.0} @@ -21208,12 +21197,6 @@ packages: hasBin: true dev: true - /prettier@3.0.2: - resolution: {integrity: sha512-o2YR9qtniXvwEZlOKbveKfDQVyqxbEIWn48Z8m3ZJjBjcCmUy3xZGIv+7AkaeuaTr6yPXJjwv07ZWlsWbEy1rQ==} - engines: {node: '>=14'} - hasBin: true - dev: true - /prettier@3.1.0: resolution: {integrity: sha512-TQLvXjq5IAibjh8EpBIkNKxO749UEWABoiIZehEPiY4GNpVdhaFKqSTu+QrlU6D2dPAfubRmtJTi4K4YkQ5eXw==} engines: {node: '>=14'} @@ -24322,7 +24305,7 @@ packages: execa: 5.1.1 globby: 11.1.0 joycon: 3.1.1 - postcss-load-config: 4.0.1(postcss@8.4.24) + postcss-load-config: 4.0.1(postcss@8.4.31) resolve-from: 5.0.0 rollup: 3.28.1 source-map: 0.8.0-beta.0