From 7df9378964936a27d17547da396a40742cbaf3b2 Mon Sep 17 00:00:00 2001 From: Jan Amann Date: Tue, 3 Sep 2024 14:31:26 +0200 Subject: [PATCH 01/62] wip --- .../src/navigation/createNavigation.test.tsx | 626 ++++++++++++++++++ .../createSharedPathnamesNavigation.tsx | 2 +- .../react-server/createNavigation.tsx | 163 +++++ .../createSharedPathnamesNavigation.tsx | 7 +- .../src/navigation/shared/redirects.tsx | 22 +- .../next-intl/src/navigation/shared/utils.tsx | 37 +- packages/next-intl/src/routing/config.tsx | 15 +- 7 files changed, 841 insertions(+), 31 deletions(-) create mode 100644 packages/next-intl/src/navigation/createNavigation.test.tsx create mode 100644 packages/next-intl/src/navigation/react-server/createNavigation.tsx diff --git a/packages/next-intl/src/navigation/createNavigation.test.tsx b/packages/next-intl/src/navigation/createNavigation.test.tsx new file mode 100644 index 000000000..9b38daf66 --- /dev/null +++ b/packages/next-intl/src/navigation/createNavigation.test.tsx @@ -0,0 +1,626 @@ +import {render, screen} from '@testing-library/react'; +import { + RedirectType, + redirect as nextRedirect, + permanentRedirect as nextPermanentRedirect +} from 'next/navigation'; +import React from 'react'; +import {renderToString} from 'react-dom/server'; +import {it, describe, vi, expect, beforeEach} from 'vitest'; +import {defineRouting, Pathnames} from '../routing'; +import {getRequestLocale} from '../server/react-server/RequestLocale'; +import {getLocalePrefix} from '../shared/utils'; +import createNavigation from './react-server/createNavigation'; +import BaseLink from './shared/BaseLink'; + +vi.mock('next/navigation', async () => { + const actual = await vi.importActual('next/navigation'); + return { + ...actual, + useParams: vi.fn(() => ({locale: 'en'})), + usePathname: vi.fn(() => '/'), + redirect: vi.fn(), + permanentRedirect: vi.fn() + }; +}); + +vi.mock('next-intl/config', () => ({ + default: async () => + ((await vi.importActual('../../src/server')) as any).getRequestConfig({ + locale: 'en' + }) +})); + +vi.mock('react'); + +// Avoids handling an async component (not supported by renderToString) +vi.mock('../../src/navigation/react-server/ServerLink', () => ({ + default({locale, localePrefix, ...rest}: any) { + const finalLocale = locale || 'en'; + const prefix = getLocalePrefix(finalLocale, localePrefix); + return ( + + ); + } +})); + +vi.mock('../../src/server/react-server/RequestLocale', () => ({ + getRequestLocale: vi.fn(() => 'en') +})); + +beforeEach(() => { + vi.mocked(getRequestLocale).mockImplementation(() => 'en'); +}); + +const locales = ['en', 'de', 'ja'] as const; +const defaultLocale = 'en' as const; + +const pathnames = { + '/': '/', + '/about': { + en: '/about', + de: '/ueber-uns', + ja: '/約' + }, + '/news/[articleSlug]-[articleId]': { + en: '/news/[articleSlug]-[articleId]', + de: '/neuigkeiten/[articleSlug]-[articleId]', + ja: '/ニュース/[articleSlug]-[articleId]' + }, + '/categories/[...parts]': { + en: '/categories/[...parts]', + de: '/kategorien/[...parts]', + ja: '/カテゴリ/[...parts]' + }, + '/catch-all/[[...parts]]': '/catch-all/[[...parts]]' +} satisfies Pathnames; + +function runInRender(cb: () => void) { + function Component() { + cb(); + return null; + } + render(); +} + +describe("localePrefix: 'always'", () => { + const {Link, getPathname, permanentRedirect, redirect} = createNavigation({ + locales, + defaultLocale, + localePrefix: 'always' + }); + + describe('Link', () => { + it('renders a prefix for the default locale', () => { + const markup = renderToString(About); + expect(markup).toContain('href="/en/about"'); + }); + + it('accepts query params', () => { + const markup = renderToString( + About + ); + expect(markup).toContain('href="/en/about?foo=bar"'); + }); + + 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'); + }); + + it('handles relative links correctly on the initial render', () => { + const markup = renderToString(Test); + expect(markup).toContain('href="test"'); + }); + + it('does not allow to use unknown locales', () => { + const markup = renderToString( + // @ts-expect-error -- Unknown locale + + Unknown + + ); + // Still works + expect(markup).toContain('href="/zh/about"'); + }); + }); + + describe('getPathname', () => { + it('can be called with an arbitrary pathname', () => { + expect(getPathname('/unknown')).toBe('/en/unknown'); + }); + + it('can switch the locale while providing an `href`', () => { + expect( + getPathname({ + href: '/about', + locale: 'de' + }) + ).toBe('/de/about'); + }); + + it('requires a locale when using an object href', () => { + // @ts-expect-error -- Missing locale + expect(getPathname({href: '/about'})) + // Still works + .toBe('/en/about'); + }); + }); + + describe.each([ + ['redirect', redirect, nextRedirect], + ['permanentRedirect', permanentRedirect, nextPermanentRedirect] + ])('%s', (_, redirectFn, nextRedirectFn) => { + it('can redirect for the default locale', () => { + runInRender(() => redirectFn('/')); + expect(nextRedirectFn).toHaveBeenLastCalledWith('/en'); + }); + + it('forwards a redirect type', () => { + runInRender(() => redirectFn('/', RedirectType.push)); + expect(nextRedirectFn).toHaveBeenLastCalledWith('/en', RedirectType.push); + }); + + it('can redirect for a different locale', () => { + runInRender(() => redirectFn({href: '/about', locale: 'de'})); + expect(nextRedirectFn).toHaveBeenLastCalledWith('/de/about'); + }); + }); +}); + +describe("localePrefix: 'always', no `locales`", () => { + const {Link, getPathname, permanentRedirect, redirect} = createNavigation({ + localePrefix: 'always' + }); + + describe('createNavigation', () => { + it('can create navigation APIs with no arguments at all', () => { + createNavigation(); + }); + + it('can not be used with `pathnames`', () => { + // @ts-expect-error -- Missing locales + createNavigation({pathnames}); + }); + }); + + describe('Link', () => { + it('renders a prefix for the current locale', () => { + const markup = renderToString(About); + expect(markup).toContain('href="/en/about"'); + }); + + it('renders a prefix for a different locale', () => { + const markup = renderToString( + + About + + ); + expect(markup).toContain('href="/zh/about"'); + }); + + it('handles relative links correctly on the initial render', () => { + const markup = renderToString(Test); + expect(markup).toContain('href="test"'); + }); + }); + + describe('getPathname', () => { + it('adds a prefix for the current locale', () => { + expect(getPathname('/about')).toBe('/en/about'); + }); + + it('adds a prefix for a different locale', () => { + expect(getPathname({href: '/about', locale: 'zh'})).toBe('/zh/about'); + }); + }); + + describe.each([ + ['redirect', redirect, nextRedirect], + ['permanentRedirect', permanentRedirect, nextPermanentRedirect] + ])('%s', (_, redirectFn, nextRedirectFn) => { + it('can redirect for the current locale', () => { + runInRender(() => redirectFn('/')); + expect(nextRedirectFn).toHaveBeenLastCalledWith('/en'); + }); + + it('can redirect for a different locale', () => { + runInRender(() => redirectFn({href: '/about', locale: 'de'})); + expect(nextRedirectFn).toHaveBeenLastCalledWith('/de/about'); + }); + }); +}); + +describe("localePrefix: 'always', with `pathnames`", () => { + const {Link, getPathname, permanentRedirect, redirect} = createNavigation({ + locales, + defaultLocale, + localePrefix: 'always', + pathnames + }); + + describe('createNavigation', () => { + it('requires `locales` for `pathnames`', () => { + // @ts-expect-error -- Missing locales + createNavigation({ + pathnames: {'/': '/'} + }); + }); + + it('can be called with a `routing` object', () => { + createNavigation( + defineRouting({ + locales: ['en', 'de'], + defaultLocale: 'en' + }) + ); + createNavigation( + defineRouting({ + locales: ['en', 'de'], + defaultLocale: 'en', + pathnames: { + home: '/', + about: { + en: '/about', + de: '/ueber-uns' + } + } + }) + ); + }); + }); + + 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'); + }); + + it('handles params', () => { + render( + + About + + ); + expect( + screen.getByRole('link', {name: 'About'}).getAttribute('href') + ).toBe('/de/neuigkeiten/launch-party-3'); + }); + + it('restricts invalid usage', () => { + // @ts-expect-error -- Unknown locale + ; + // @ts-expect-error -- Unknown pathname + ; + // @ts-expect-error -- Missing params + ; + }); + }); + + describe('getPathname', () => { + it('can be called with a known pathname', () => { + expect(getPathname('/about')).toBe('/en/about'); + expect(getPathname({pathname: '/about', query: {foo: 'bar'}})).toBe( + '/en/about?foo=bar' + ); + }); + + it('can resolve a pathname with params for the current locale via a short-hand', () => { + expect( + getPathname({ + pathname: '/news/[articleSlug]-[articleId]', + params: { + articleId: 3, + articleSlug: 'launch-party' + }, + query: {foo: 'bar'} + }) + ).toBe('/en/news/launch-party-3?foo=bar'); + }); + + it('can switch the locale while providing an `href`', () => { + expect( + getPathname({ + href: { + pathname: '/news/[articleSlug]-[articleId]', + params: { + articleId: 3, + articleSlug: 'launch-party' + }, + query: {foo: 'bar'} + }, + locale: 'de' + }) + ).toBe('/de/neuigkeiten/launch-party-3?foo=bar'); + }); + + it('can not be called with an arbitrary pathname', () => { + // @ts-expect-error -- Unknown pathname + expect(getPathname('/unknown')).toBe('/en/unknown'); + }); + }); + + describe.each([ + ['redirect', redirect, nextRedirect], + ['permanentRedirect', permanentRedirect, nextPermanentRedirect] + ])('%s', (_, redirectFn, nextRedirectFn) => { + it('can redirect for the default locale', () => { + runInRender(() => redirectFn('/')); + expect(nextRedirectFn).toHaveBeenLastCalledWith('/en'); + }); + + it('can redirect with params', () => { + runInRender(() => + redirectFn({ + pathname: '/news/[articleSlug]-[articleId]', + params: { + articleId: 3, + articleSlug: 'launch-party' + }, + query: {foo: 'bar'} + }) + ); + expect(nextRedirectFn).toHaveBeenLastCalledWith( + '/en/news/launch-party-3?foo=bar' + ); + }); + + it('can redirect for a different locale', () => { + runInRender(() => redirectFn({href: '/about', locale: 'de'})); + expect(nextRedirectFn).toHaveBeenLastCalledWith('/de/ueber-uns'); + }); + + it('can redirect for a different locale with params', () => { + runInRender(() => + redirectFn({ + href: { + pathname: '/news/[articleSlug]-[articleId]', + params: { + articleId: 3, + articleSlug: 'launch-party' + }, + query: {foo: 'bar'} + }, + locale: 'de' + }) + ); + expect(nextRedirectFn).toHaveBeenLastCalledWith( + '/de/neuigkeiten/launch-party-3?foo=bar' + ); + }); + + it('can not be called with an arbitrary pathname', () => { + // @ts-expect-error -- Unknown pathname + runInRender(() => redirectFn('/unknown')); + expect(nextRedirectFn).toHaveBeenLastCalledWith('/en/unknown'); + }); + + it('forwards a redirect type', () => { + runInRender(() => redirectFn('/', RedirectType.push)); + expect(nextRedirectFn).toHaveBeenLastCalledWith('/en', RedirectType.push); + }); + }); +}); + +describe("localePrefix: 'as-needed'", () => { + const {getPathname, permanentRedirect, redirect} = createNavigation({ + locales, + defaultLocale, + localePrefix: 'as-needed' + }); + + // describe.todo('Link', () => { + // // nooope. but only if we are already on that locale? for cookie switching + // // maybe: + // // - initial render without locale (correct link) + // // - add locale on client side to switch cookie? + // it.only('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'); + // }); + + // it('handles relative links correctly on the initial render', () => { + // const markup = renderToString(Test); + // expect(markup).toContain('href="test"'); + // }); + // }); + + describe('getPathname', () => { + it('can be called with an arbitrary pathname', () => { + expect(getPathname('/unknown')).toBe('/unknown'); + }); + + it('can switch the locale while providing an `href`', () => { + expect( + getPathname({ + href: '/about', + locale: 'de' + }) + ).toBe('/de/about'); + }); + + it('requires a locale when using an object href', () => { + // @ts-expect-error -- Missing locale + expect(getPathname({href: '/about'})) + // Still works + .toBe('/about'); + }); + }); + + describe.each([ + ['redirect', redirect, nextRedirect], + ['permanentRedirect', permanentRedirect, nextPermanentRedirect] + ])('%s', (_, redirectFn, nextRedirectFn) => { + it('can redirect for the default locale', () => { + runInRender(() => redirectFn('/')); + expect(nextRedirectFn).toHaveBeenLastCalledWith('/'); + }); + + it('forwards a redirect type', () => { + runInRender(() => redirectFn('/', RedirectType.push)); + expect(nextRedirectFn).toHaveBeenLastCalledWith('/', RedirectType.push); + }); + + it('can redirect for a different locale', () => { + runInRender(() => redirectFn({href: '/about', locale: 'de'})); + expect(nextRedirectFn).toHaveBeenLastCalledWith('/de/about'); + }); + }); +}); + +// describe("localePrefix: 'always', with `prefixes`", () => {}) +// describe("localePrefix: 'as-necessary', no `locales`", () => {}) +// describe("localePrefix: 'as-necessary', with `domains`", () => {}) +// describe("localePrefix: 'never', with `domains`", () => {}) +// describe("localePrefix: 'always', with `domains`", () => {}) + +describe("localePrefix: 'never'", () => { + const {Link, getPathname, permanentRedirect, redirect} = createNavigation({ + locales, + defaultLocale, + localePrefix: 'never' + }); + + describe('Link', () => { + it('renders no 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('getPathname', () => { + it('can be called with an arbitrary pathname', () => { + expect(getPathname('/unknown')).toBe('/unknown'); + }); + + it('can switch the locale while providing an `href`', () => { + expect( + getPathname({ + href: '/about', + locale: 'de' + }) + ).toBe('/de/about'); + }); + + it('requires a locale when using an object href', () => { + // @ts-expect-error -- Missing locale + expect(getPathname({href: '/about'})) + // Still works + .toBe('/about'); + }); + }); + + describe.each([ + ['redirect', redirect, nextRedirect], + ['permanentRedirect', permanentRedirect, nextPermanentRedirect] + ])('%s', (_, redirectFn, nextRedirectFn) => { + it('can redirect for the default locale', () => { + runInRender(() => redirectFn('/')); + expect(nextRedirectFn).toHaveBeenLastCalledWith('/'); + }); + + it('forwards a redirect type', () => { + runInRender(() => redirectFn('/', RedirectType.push)); + expect(nextRedirectFn).toHaveBeenLastCalledWith('/', RedirectType.push); + }); + + it('can redirect for a different locale', () => { + runInRender(() => redirectFn({href: '/about', locale: 'de'})); + expect(nextRedirectFn).toHaveBeenLastCalledWith('/de/about'); + }); + }); +}); diff --git a/packages/next-intl/src/navigation/react-client/createSharedPathnamesNavigation.tsx b/packages/next-intl/src/navigation/react-client/createSharedPathnamesNavigation.tsx index 4050010f9..cdc10cb20 100644 --- a/packages/next-intl/src/navigation/react-client/createSharedPathnamesNavigation.tsx +++ b/packages/next-intl/src/navigation/react-client/createSharedPathnamesNavigation.tsx @@ -12,7 +12,7 @@ import useBaseRouter from './useBaseRouter'; export default function createSharedPathnamesNavigation< const AppLocales extends Locales ->(routing?: RoutingConfigSharedNavigation) { +>(routing?: RoutingConfigSharedNavigation) { const localePrefix = receiveLocalePrefixConfig(routing?.localePrefix); type LinkProps = Omit< diff --git a/packages/next-intl/src/navigation/react-server/createNavigation.tsx b/packages/next-intl/src/navigation/react-server/createNavigation.tsx new file mode 100644 index 000000000..d85005e41 --- /dev/null +++ b/packages/next-intl/src/navigation/react-server/createNavigation.tsx @@ -0,0 +1,163 @@ +import { + permanentRedirect as nextPermanentRedirect, + redirect as nextRedirect +} from 'next/navigation'; +import React, {ComponentProps} from 'react'; +import { + receiveRoutingConfig, + RoutingConfigLocalizedNavigation, + RoutingConfigSharedNavigation +} from '../../routing/config'; +import {Locales, Pathnames} from '../../routing/types'; +import {getRequestLocale} from '../../server/react-server/RequestLocale'; +import {ParametersExceptFirst} from '../../shared/types'; +import { + HrefOrHrefWithParams, + HrefOrUrlObjectWithParams, + applyPathnamePrefix, + compileLocalizedPathname, + normalizeNameOrNameWithParams +} from '../shared/utils'; +import ServerLink from './ServerLink'; + +export default function createNavigation< + const AppLocales extends Locales, + const AppPathnames extends Pathnames = never +>( + routing?: [AppPathnames] extends [never] + ? RoutingConfigSharedNavigation | undefined + : RoutingConfigLocalizedNavigation +) { + type Locale = AppLocales extends never ? string : AppLocales[number]; + + // Slightly different than e.g. `redirect` which only allows to pass `query` + // type LinkHref + + const config = receiveRoutingConfig(routing); + const pathnames = (config as any).pathnames as [AppPathnames] extends [never] + ? undefined + : AppPathnames; + + function getCurrentLocale() { + return getRequestLocale() as typeof config.locales extends undefined + ? string + : Locale; + } + + type LinkProps = Omit< + ComponentProps, + 'href' | 'localePrefix' + > & { + href: [AppPathnames] extends [never] + ? ComponentProps['href'] + : HrefOrUrlObjectWithParams; + locale?: Locale; + }; + function Link({ + href, + locale, + ...rest + }: LinkProps) { + const curLocale = getCurrentLocale(); + const finalLocale = locale || curLocale; + + return ( + ({ + locale: finalLocale, + // @ts-expect-error -- This is ok + pathname: href, + // @ts-expect-error -- This is ok + params: typeof href === 'object' ? href.params : undefined, + pathnames + }) + : (href as string) + } + locale={locale} + localePrefix={config.localePrefix} + {...rest} + /> + ); + } + + // TODO: Should this be called in Link? Maybe not, we can hydrate for one case there. Or: Call it with localePrefix: 'always' and again on the client side? + // New: Locale is now optional (do we want this?) + // New: accepts plain href argument + // New: getPathname is available for shared pathnames + function getPathname( + href: [AppPathnames] extends [never] + ? string | {locale: Locale; href: string} + : + | HrefOrHrefWithParams + | { + locale: Locale; + href: HrefOrHrefWithParams; + } + ) { + let locale; + // @ts-expect-error -- This is ok + if (typeof href === 'object') locale = href.locale; + const hasProvidedLocale = locale != null; + if (!locale) locale = getCurrentLocale(); + + let pathname: string; + if (pathnames == null) { + // @ts-expect-error -- This is ok + pathname = typeof href === 'string' ? href : href.href; + } else { + pathname = compileLocalizedPathname({ + // @ts-expect-error -- This is ok + ...normalizeNameOrNameWithParams(href), + locale, + // @ts-expect-error -- This is ok + pathnames: config.pathnames + }); + } + + // TODO: There might be only one shot here, for as-necessary + // and domains, should we apply the prefix here? Alternative + // would be reading `host`, but that breaks SSG. If you want + // to get the first shot right, pass a `domain` here (then + // the user opts into dynamic rendering) + return applyPathnamePrefix({ + pathname, + locale, + curLocale: getCurrentLocale(), + routing: config, + force: hasProvidedLocale + }); + } + + function redirect( + href: Parameters[0], + ...args: ParametersExceptFirst + ) { + return nextRedirect(getPathname(href), ...args); + } + + function permanentRedirect( + href: Parameters[0], + ...args: ParametersExceptFirst + ) { + return nextPermanentRedirect(getPathname(href), ...args); + } + + function notSupported(hookName: string) { + return () => { + throw new Error( + `\`${hookName}\` is not supported in Server Components. You can use this hook if you convert the component to a Client Component.` + ); + }; + } + + return { + Link, + redirect, + permanentRedirect, + getPathname, + usePathname: notSupported('usePathname'), + useRouter: notSupported('useRouter') + }; +} diff --git a/packages/next-intl/src/navigation/react-server/createSharedPathnamesNavigation.tsx b/packages/next-intl/src/navigation/react-server/createSharedPathnamesNavigation.tsx index deaf31468..687f29d7e 100644 --- a/packages/next-intl/src/navigation/react-server/createSharedPathnamesNavigation.tsx +++ b/packages/next-intl/src/navigation/react-server/createSharedPathnamesNavigation.tsx @@ -10,7 +10,7 @@ import {serverPermanentRedirect, serverRedirect} from './redirects'; export default function createSharedPathnamesNavigation< AppLocales extends Locales ->(routing?: RoutingConfigSharedNavigation) { +>(routing?: RoutingConfigSharedNavigation) { const localePrefix = receiveLocalePrefixConfig(routing?.localePrefix); function notSupported(hookName: string) { @@ -22,10 +22,7 @@ export default function createSharedPathnamesNavigation< } function Link( - props: Omit< - ComponentProps>, - 'localePrefix' | 'locales' - > + props: Omit>, 'localePrefix'> ) { return localePrefix={localePrefix} {...props} />; } diff --git a/packages/next-intl/src/navigation/shared/redirects.tsx b/packages/next-intl/src/navigation/shared/redirects.tsx index 95a53a6f5..3c267fbd6 100644 --- a/packages/next-intl/src/navigation/shared/redirects.tsx +++ b/packages/next-intl/src/navigation/shared/redirects.tsx @@ -4,11 +4,7 @@ import { } from 'next/navigation'; import {Locales, LocalePrefixConfigVerbose} from '../../routing/types'; import {ParametersExceptFirst} from '../../shared/types'; -import { - getLocalePrefix, - isLocalizableHref, - prefixPathname -} from '../../shared/utils'; +import {applyPathnamePrefix} from './utils'; function createRedirectFn(redirectFn: typeof nextRedirect) { return function baseRedirect( @@ -19,13 +15,15 @@ function createRedirectFn(redirectFn: typeof nextRedirect) { }, ...args: ParametersExceptFirst ) { - const prefix = getLocalePrefix(params.locale, params.localePrefix); - const localizedPathname = - params.localePrefix.mode === 'never' || - !isLocalizableHref(params.pathname) - ? params.pathname - : prefixPathname(prefix, params.pathname); - return redirectFn(localizedPathname, ...args); + return redirectFn( + applyPathnamePrefix({ + ...params, + curLocale: params.locale, + // TODO: Refactor fn signature to reduce bundle size? + routing: {localePrefix: params.localePrefix} + }), + ...args + ); }; } diff --git a/packages/next-intl/src/navigation/shared/utils.tsx b/packages/next-intl/src/navigation/shared/utils.tsx index b5cb714da..804a9fef4 100644 --- a/packages/next-intl/src/navigation/shared/utils.tsx +++ b/packages/next-intl/src/navigation/shared/utils.tsx @@ -1,10 +1,14 @@ import type {ParsedUrlQueryInput} from 'node:querystring'; import type {UrlObject} from 'url'; +import {ResolvedRoutingConfig} from '../../routing/config'; import {Locales, Pathnames} from '../../routing/types'; import { matchesPathname, getSortedPathnames, - normalizeTrailingSlash + normalizeTrailingSlash, + isLocalizableHref, + prefixPathname, + getLocalePrefix } from '../../shared/utils'; import StrictParams from './StrictParams'; @@ -38,8 +42,11 @@ export function normalizeNameOrNameWithParams( pathname: Pathname; params?: StrictParams; } { - // @ts-expect-error -- `extends string` in the generic unfortunately weakens the type - return typeof href === 'string' ? {pathname: href as Pathname} : href; + return typeof href === 'string' + ? {pathname: href as Pathname} + : 'locale' in href + ? normalizeNameOrNameWithParams(href.href) + : href; } export function serializeSearchParams( @@ -198,3 +205,27 @@ export function getBasePath( return windowPathname.replace(pathname, ''); } } + +export function applyPathnamePrefix(params: { + pathname: string; + locale: Locales[number]; + curLocale: Locales[number]; + routing: Pick, 'localePrefix' | 'domains'>; + force?: boolean; +}): string { + const {mode} = params.routing.localePrefix; + const shouldPrefix = + isLocalizableHref(params.pathname) && + (params.force || + mode === 'always' || + (mode === 'as-needed' && + params.locale !== params.curLocale && + !params.routing.domains)); + + return shouldPrefix + ? prefixPathname( + getLocalePrefix(params.locale, params.routing.localePrefix), + params.pathname + ) + : params.pathname; +} diff --git a/packages/next-intl/src/routing/config.tsx b/packages/next-intl/src/routing/config.tsx index 3a541ffc2..a246bedb7 100644 --- a/packages/next-intl/src/routing/config.tsx +++ b/packages/next-intl/src/routing/config.tsx @@ -45,16 +45,11 @@ export type RoutingConfig< pathnames: AppPathnames; }); -export type RoutingConfigSharedNavigation< - AppLocales extends Locales, - AppPathnames extends Pathnames -> = Omit< - RoutingConfig, +export type RoutingConfigSharedNavigation = Omit< + RoutingConfig, 'defaultLocale' | 'locales' | 'pathnames' > & - Partial< - Pick, 'defaultLocale' | 'locales'> - >; + Partial, 'defaultLocale' | 'locales'>>; export type RoutingConfigLocalizedNavigation< AppLocales extends Locales, @@ -78,10 +73,10 @@ export function receiveRoutingConfig< AppLocales extends Locales, AppPathnames extends Pathnames, Config extends Partial> ->(input: Config) { +>(input?: Config) { return { ...input, - localePrefix: receiveLocalePrefixConfig(input.localePrefix) + localePrefix: receiveLocalePrefixConfig(input?.localePrefix) }; } From 48987934145863a3b2c8e38c40312e9e340ced45 Mon Sep 17 00:00:00 2001 From: Jan Amann Date: Tue, 3 Sep 2024 16:13:33 +0200 Subject: [PATCH 02/62] Fix some types --- .../src/navigation/createNavigation.test.tsx | 8 +++-- .../react-server/createNavigation.tsx | 31 ++++++++++++++----- .../next-intl/src/navigation/shared/utils.tsx | 14 ++++++--- packages/next-intl/src/routing/config.tsx | 2 +- 4 files changed, 40 insertions(+), 15 deletions(-) diff --git a/packages/next-intl/src/navigation/createNavigation.test.tsx b/packages/next-intl/src/navigation/createNavigation.test.tsx index 9b38daf66..5d6b9f24d 100644 --- a/packages/next-intl/src/navigation/createNavigation.test.tsx +++ b/packages/next-intl/src/navigation/createNavigation.test.tsx @@ -119,11 +119,15 @@ describe("localePrefix: 'always'", () => { it('renders an object href', () => { render( - About + + About + ); expect( screen.getByRole('link', {name: 'About'}).getAttribute('href') - ).toBe('/about?foo=bar'); + ).toBe('//www.test.de/about?foo=bar'); }); it('handles params', () => { diff --git a/packages/next-intl/src/navigation/react-server/createNavigation.tsx b/packages/next-intl/src/navigation/react-server/createNavigation.tsx index d85005e41..9e22c8338 100644 --- a/packages/next-intl/src/navigation/react-server/createNavigation.tsx +++ b/packages/next-intl/src/navigation/react-server/createNavigation.tsx @@ -5,6 +5,7 @@ import { import React, {ComponentProps} from 'react'; import { receiveRoutingConfig, + ResolvedRoutingConfig, RoutingConfigLocalizedNavigation, RoutingConfigSharedNavigation } from '../../routing/config'; @@ -33,15 +34,20 @@ export default function createNavigation< // Slightly different than e.g. `redirect` which only allows to pass `query` // type LinkHref - const config = receiveRoutingConfig(routing); + const config = receiveRoutingConfig( + routing || {} + ) as typeof routing extends undefined + ? Pick, 'localePrefix'> + : [AppPathnames] extends [never] + ? ResolvedRoutingConfig + : ResolvedRoutingConfig; + const pathnames = (config as any).pathnames as [AppPathnames] extends [never] ? undefined : AppPathnames; function getCurrentLocale() { - return getRequestLocale() as typeof config.locales extends undefined - ? string - : Locale; + return getRequestLocale() as Locale; } type LinkProps = Omit< @@ -96,9 +102,18 @@ export default function createNavigation< href: HrefOrHrefWithParams; } ) { + let hrefArg: [AppPathnames] extends [never] + ? string + : HrefOrHrefWithParams; let locale; - // @ts-expect-error -- This is ok - if (typeof href === 'object') locale = href.locale; + if (typeof href === 'object' && 'locale' in href) { + locale = href.locale; + // @ts-expect-error -- This is implied + hrefArg = href.href; + } else { + hrefArg = href as typeof hrefArg; + } + const hasProvidedLocale = locale != null; if (!locale) locale = getCurrentLocale(); @@ -108,10 +123,10 @@ export default function createNavigation< pathname = typeof href === 'string' ? href : href.href; } else { pathname = compileLocalizedPathname({ - // @ts-expect-error -- This is ok - ...normalizeNameOrNameWithParams(href), locale, // @ts-expect-error -- This is ok + ...normalizeNameOrNameWithParams(hrefArg), + // @ts-expect-error -- This is ok pathnames: config.pathnames }); } diff --git a/packages/next-intl/src/navigation/shared/utils.tsx b/packages/next-intl/src/navigation/shared/utils.tsx index 804a9fef4..a376c29a8 100644 --- a/packages/next-intl/src/navigation/shared/utils.tsx +++ b/packages/next-intl/src/navigation/shared/utils.tsx @@ -37,16 +37,22 @@ export type HrefOrHrefWithParams = HrefOrHrefWithParamsImpl< >; export function normalizeNameOrNameWithParams( - href: HrefOrHrefWithParams + href: + | HrefOrHrefWithParams + | { + locale: string; + href: HrefOrHrefWithParams; + } ): { pathname: Pathname; params?: StrictParams; } { return typeof href === 'string' ? {pathname: href as Pathname} - : 'locale' in href - ? normalizeNameOrNameWithParams(href.href) - : href; + : (href as { + pathname: Pathname; + params?: StrictParams; + }); } export function serializeSearchParams( diff --git a/packages/next-intl/src/routing/config.tsx b/packages/next-intl/src/routing/config.tsx index a246bedb7..eefa31bd0 100644 --- a/packages/next-intl/src/routing/config.tsx +++ b/packages/next-intl/src/routing/config.tsx @@ -73,7 +73,7 @@ export function receiveRoutingConfig< AppLocales extends Locales, AppPathnames extends Pathnames, Config extends Partial> ->(input?: Config) { +>(input: Config) { return { ...input, localePrefix: receiveLocalePrefixConfig(input?.localePrefix) From faedffad6cf83c697f149c7a9ce32d7190707fc8 Mon Sep 17 00:00:00 2001 From: Jan Amann Date: Tue, 3 Sep 2024 16:37:51 +0200 Subject: [PATCH 03/62] set up size limits --- packages/next-intl/.size-limit.ts | 36 +++++++++++++++++++++++++++++++ packages/use-intl/.size-limit.ts | 6 +++--- 2 files changed, 39 insertions(+), 3 deletions(-) diff --git a/packages/next-intl/.size-limit.ts b/packages/next-intl/.size-limit.ts index 76974f868..6b6bee7bb 100644 --- a/packages/next-intl/.size-limit.ts +++ b/packages/next-intl/.size-limit.ts @@ -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', + import: '{createSharedPathnamesNavigation}', limit: '3.155 KB' }, { + name: 'import {createLocalizedPathnamesNavigation} from \'next-intl/navigation\' (react-client)', + path: 'dist/production/navigation.react-client.js', + import: '{createLocalizedPathnamesNavigation}', + limit: '3.155 KB' + }, + { + name: 'import {createNavigation} from \'next-intl/navigation\' (react-client)', + path: 'dist/production/navigation.react-client.js', + import: '{createNavigation}', + limit: '3.155 KB' + }, + { + name: 'import {createSharedPathnamesNavigation} from \'next-intl/navigation\' (react-server)', path: 'dist/production/navigation.react-server.js', + import: '{createSharedPathnamesNavigation}', limit: '15.845 KB' }, { + name: 'import {createLocalizedPathnamesNavigation} from \'next-intl/navigation\' (react-server)', + path: 'dist/production/navigation.react-server.js', + import: '{createLocalizedPathnamesNavigation}', + limit: '15.845 KB' + }, + { + name: 'import {createNavigation} from \'next-intl/navigation\' (react-server)', + path: 'dist/production/navigation.react-server.js', + import: '{createNavigation}', + limit: '15.91 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.595 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' diff --git a/packages/use-intl/.size-limit.ts b/packages/use-intl/.size-limit.ts index 78561a217..c340a2828 100644 --- a/packages/use-intl/.size-limit.ts +++ b/packages/use-intl/.size-limit.ts @@ -2,20 +2,20 @@ import type {SizeLimitConfig} from 'size-limit'; const config: SizeLimitConfig = [ { - name: './ (ESM)', + name: 'import * from \'use-intl\' (ESM)', import: '*', path: 'dist/esm/index.js', limit: '14.085 kB' }, { - name: './ (no useTranslations, ESM)', + name: 'import {IntlProvider, useLocale, useNow, useTimeZone, useMessages, useFormatter} from \'use-intl\' (ESM)', path: 'dist/esm/index.js', import: '{IntlProvider, useLocale, useNow, useTimeZone, useMessages, useFormatter}', limit: '2.865 kB' }, { - name: './ (CJS)', + name: 'import * from \'use-intl\' (CJS)', path: 'dist/production/index.js', limit: '15.65 kB' } From 978c5602294c5526a95c4b87a26f2e94a2ad8106 Mon Sep 17 00:00:00 2001 From: Jan Amann Date: Tue, 3 Sep 2024 16:38:24 +0200 Subject: [PATCH 04/62] keep legacy logic to fix test --- .../src/navigation/shared/redirects.tsx | 26 ++++++++++++------- 1 file changed, 16 insertions(+), 10 deletions(-) diff --git a/packages/next-intl/src/navigation/shared/redirects.tsx b/packages/next-intl/src/navigation/shared/redirects.tsx index 3c267fbd6..9720825b0 100644 --- a/packages/next-intl/src/navigation/shared/redirects.tsx +++ b/packages/next-intl/src/navigation/shared/redirects.tsx @@ -4,7 +4,11 @@ import { } from 'next/navigation'; import {Locales, LocalePrefixConfigVerbose} from '../../routing/types'; import {ParametersExceptFirst} from '../../shared/types'; -import {applyPathnamePrefix} from './utils'; +import { + getLocalePrefix, + isLocalizableHref, + prefixPathname +} from '../../shared/utils'; function createRedirectFn(redirectFn: typeof nextRedirect) { return function baseRedirect( @@ -15,15 +19,17 @@ function createRedirectFn(redirectFn: typeof nextRedirect) { }, ...args: ParametersExceptFirst ) { - return redirectFn( - applyPathnamePrefix({ - ...params, - curLocale: params.locale, - // TODO: Refactor fn signature to reduce bundle size? - routing: {localePrefix: params.localePrefix} - }), - ...args - ); + const prefix = getLocalePrefix(params.locale, params.localePrefix); + + // This logic is considered legacy and is replaced by `applyPathnamePrefix`. + // We keep it this way for now for backwards compatibility. + const localizedPathname = + params.localePrefix.mode === 'never' || + !isLocalizableHref(params.pathname) + ? params.pathname + : prefixPathname(prefix, params.pathname); + + return redirectFn(localizedPathname, ...args); }; } From 73d29ab27d6b24ddacdf141bc67061904e49b82d Mon Sep 17 00:00:00 2001 From: Jan Amann Date: Tue, 3 Sep 2024 18:12:51 +0200 Subject: [PATCH 05/62] Fix redirect edge case --- .../src/navigation/createNavigation.test.tsx | 33 +++++++++++-------- .../react-client/createNavigation.tsx | 1 + .../src/navigation/react-client/index.tsx | 1 + .../react-server/createNavigation.tsx | 20 ++++++++--- .../src/navigation/react-server/index.tsx | 1 + 5 files changed, 37 insertions(+), 19 deletions(-) create mode 100644 packages/next-intl/src/navigation/react-client/createNavigation.tsx diff --git a/packages/next-intl/src/navigation/createNavigation.test.tsx b/packages/next-intl/src/navigation/createNavigation.test.tsx index 5d6b9f24d..d90a1b898 100644 --- a/packages/next-intl/src/navigation/createNavigation.test.tsx +++ b/packages/next-intl/src/navigation/createNavigation.test.tsx @@ -515,11 +515,11 @@ describe("localePrefix: 'as-needed'", () => { // }); describe('getPathname', () => { - it('can be called with an arbitrary pathname', () => { + it('does not add a prefix when the current locale is the default locale', () => { expect(getPathname('/unknown')).toBe('/unknown'); }); - it('can switch the locale while providing an `href`', () => { + it('adds a prefix for a secondary locale', () => { expect( getPathname({ href: '/about', @@ -534,13 +534,17 @@ describe("localePrefix: 'as-needed'", () => { // Still works .toBe('/about'); }); + + it('does not add a prefix for the default locale', () => { + expect(getPathname({href: '/about', locale: 'en'})).toBe('/about'); + }); }); describe.each([ ['redirect', redirect, nextRedirect], ['permanentRedirect', permanentRedirect, nextPermanentRedirect] ])('%s', (_, redirectFn, nextRedirectFn) => { - it('can redirect for the default locale', () => { + it('does not add a prefix when redirecting within the default locale', () => { runInRender(() => redirectFn('/')); expect(nextRedirectFn).toHaveBeenLastCalledWith('/'); }); @@ -550,16 +554,22 @@ describe("localePrefix: 'as-needed'", () => { expect(nextRedirectFn).toHaveBeenLastCalledWith('/', RedirectType.push); }); - it('can redirect for a different locale', () => { + it('adds a prefix when redirecting to a secondary locale', () => { runInRender(() => redirectFn({href: '/about', locale: 'de'})); expect(nextRedirectFn).toHaveBeenLastCalledWith('/de/about'); }); + + it('adds a prefix when redirecting from a different locale to the default locale', () => { + vi.mocked(getRequestLocale).mockImplementation(() => 'de'); + runInRender(() => redirectFn({href: '/about', locale: 'en'})); + expect(nextRedirectFn).toHaveBeenLastCalledWith('/en/about'); + }); }); }); // describe("localePrefix: 'always', with `prefixes`", () => {}) -// describe("localePrefix: 'as-necessary', no `locales`", () => {}) -// describe("localePrefix: 'as-necessary', with `domains`", () => {}) +// describe("localePrefix: 'as-needed', no `locales`", () => {}) +// describe("localePrefix: 'as-needed', with `domains`", () => {}) // describe("localePrefix: 'never', with `domains`", () => {}) // describe("localePrefix: 'always', with `domains`", () => {}) @@ -587,17 +597,12 @@ describe("localePrefix: 'never'", () => { }); describe('getPathname', () => { - it('can be called with an arbitrary pathname', () => { + it('does not add a prefix when staying on the current locale', () => { expect(getPathname('/unknown')).toBe('/unknown'); }); - it('can switch the locale while providing an `href`', () => { - expect( - getPathname({ - href: '/about', - locale: 'de' - }) - ).toBe('/de/about'); + it('does not add a prefix when specifying a secondary locale', () => { + expect(getPathname({href: '/about', locale: 'de'})).toBe('/about'); }); it('requires a locale when using an object href', () => { diff --git a/packages/next-intl/src/navigation/react-client/createNavigation.tsx b/packages/next-intl/src/navigation/react-client/createNavigation.tsx new file mode 100644 index 000000000..4fc2d2b77 --- /dev/null +++ b/packages/next-intl/src/navigation/react-client/createNavigation.tsx @@ -0,0 +1 @@ +export default function createNavigation() {} diff --git a/packages/next-intl/src/navigation/react-client/index.tsx b/packages/next-intl/src/navigation/react-client/index.tsx index f814e8378..2cc3b23d3 100644 --- a/packages/next-intl/src/navigation/react-client/index.tsx +++ b/packages/next-intl/src/navigation/react-client/index.tsx @@ -1,5 +1,6 @@ export {default as createSharedPathnamesNavigation} from './createSharedPathnamesNavigation'; export {default as createLocalizedPathnamesNavigation} from './createLocalizedPathnamesNavigation'; +export {default as createNavigation} from './createNavigation'; import type { Pathnames as PathnamesDeprecatedExport, diff --git a/packages/next-intl/src/navigation/react-server/createNavigation.tsx b/packages/next-intl/src/navigation/react-server/createNavigation.tsx index 9e22c8338..a4fab608e 100644 --- a/packages/next-intl/src/navigation/react-server/createNavigation.tsx +++ b/packages/next-intl/src/navigation/react-server/createNavigation.tsx @@ -100,7 +100,9 @@ export default function createNavigation< | { locale: Locale; href: HrefOrHrefWithParams; - } + }, + /** @private */ + forcePrefix?: boolean ) { let hrefArg: [AppPathnames] extends [never] ? string @@ -114,7 +116,6 @@ export default function createNavigation< hrefArg = href as typeof hrefArg; } - const hasProvidedLocale = locale != null; if (!locale) locale = getCurrentLocale(); let pathname: string; @@ -141,22 +142,31 @@ export default function createNavigation< locale, curLocale: getCurrentLocale(), routing: config, - force: hasProvidedLocale + force: forcePrefix }); } + function baseRedirect( + fn: typeof nextRedirect | typeof nextPermanentRedirect, + href: Parameters[0], + ...args: ParametersExceptFirst + ) { + const isChangingLocale = typeof href === 'object' && 'locale' in href; + return fn(getPathname(href, isChangingLocale), ...args); + } + function redirect( href: Parameters[0], ...args: ParametersExceptFirst ) { - return nextRedirect(getPathname(href), ...args); + return baseRedirect(nextRedirect, href, ...args); } function permanentRedirect( href: Parameters[0], ...args: ParametersExceptFirst ) { - return nextPermanentRedirect(getPathname(href), ...args); + return baseRedirect(nextPermanentRedirect, href, ...args); } function notSupported(hookName: string) { diff --git a/packages/next-intl/src/navigation/react-server/index.tsx b/packages/next-intl/src/navigation/react-server/index.tsx index fc1e780c1..88636a437 100644 --- a/packages/next-intl/src/navigation/react-server/index.tsx +++ b/packages/next-intl/src/navigation/react-server/index.tsx @@ -1,3 +1,4 @@ export {default as createSharedPathnamesNavigation} from './createSharedPathnamesNavigation'; export {default as createLocalizedPathnamesNavigation} from './createLocalizedPathnamesNavigation'; +export {default as createNavigation} from './createNavigation'; export type {Pathnames} from '../../routing/types'; From 243c41d1d2ae6e20241431850eb817669e60176e Mon Sep 17 00:00:00 2001 From: Jan Amann Date: Wed, 4 Sep 2024 10:06:22 +0200 Subject: [PATCH 06/62] More tests, fix for `as-necessary` --- .../src/navigation/createNavigation.test.tsx | 75 ++++++++++++++++--- .../react-server/createNavigation.tsx | 1 - .../next-intl/src/navigation/shared/utils.tsx | 6 +- 3 files changed, 69 insertions(+), 13 deletions(-) diff --git a/packages/next-intl/src/navigation/createNavigation.test.tsx b/packages/next-intl/src/navigation/createNavigation.test.tsx index d90a1b898..70bec1d31 100644 --- a/packages/next-intl/src/navigation/createNavigation.test.tsx +++ b/packages/next-intl/src/navigation/createNavigation.test.tsx @@ -2,7 +2,8 @@ import {render, screen} from '@testing-library/react'; import { RedirectType, redirect as nextRedirect, - permanentRedirect as nextPermanentRedirect + permanentRedirect as nextPermanentRedirect, + useParams as nextUseParams } from 'next/navigation'; import React from 'react'; import {renderToString} from 'react-dom/server'; @@ -36,7 +37,7 @@ vi.mock('react'); // Avoids handling an async component (not supported by renderToString) vi.mock('../../src/navigation/react-server/ServerLink', () => ({ default({locale, localePrefix, ...rest}: any) { - const finalLocale = locale || 'en'; + const finalLocale = locale || nextUseParams().locale; const prefix = getLocalePrefix(finalLocale, localePrefix); return ( ({ getRequestLocale: vi.fn(() => 'en') })); +function mockCurrentLocale(locale: string) { + vi.mocked(getRequestLocale).mockImplementation(() => locale); + vi.mocked(nextUseParams<{locale: string}>).mockImplementation(() => ({ + locale + })); +} + beforeEach(() => { - vi.mocked(getRequestLocale).mockImplementation(() => 'en'); + mockCurrentLocale('en'); }); const locales = ['en', 'de', 'ja'] as const; @@ -96,11 +104,17 @@ describe("localePrefix: 'always'", () => { }); describe('Link', () => { - it('renders a prefix for the default locale', () => { + it('renders a prefix when currently on the default locale', () => { const markup = renderToString(About); expect(markup).toContain('href="/en/about"'); }); + it('renders a prefix when currently on a secondary locale', () => { + mockCurrentLocale('de'); + const markup = renderToString(About); + expect(markup).toContain('href="/de/about"'); + }); + it('accepts query params', () => { const markup = renderToString( About @@ -163,6 +177,11 @@ describe("localePrefix: 'always'", () => { expect(getPathname('/unknown')).toBe('/en/unknown'); }); + it('adds a prefix when currently on a secondary locale', () => { + mockCurrentLocale('de'); + expect(getPathname('/about')).toBe('/de/about'); + }); + it('can switch the locale while providing an `href`', () => { expect( getPathname({ @@ -304,11 +323,17 @@ describe("localePrefix: 'always', with `pathnames`", () => { }); describe('Link', () => { - it('renders a prefix for the default locale', () => { + it('renders a prefix when currently on the default locale', () => { const markup = renderToString(About); expect(markup).toContain('href="/en/about"'); }); + it('renders a prefix when currently on a secondary locale', () => { + mockCurrentLocale('de'); + const markup = renderToString(About); + expect(markup).toContain('href="/de/ueber-uns"'); + }); + it('renders a prefix for a different locale', () => { const markup = renderToString( @@ -519,7 +544,12 @@ describe("localePrefix: 'as-needed'", () => { expect(getPathname('/unknown')).toBe('/unknown'); }); - it('adds a prefix for a secondary locale', () => { + it('adds a prefix when currently on a secondary locale', () => { + mockCurrentLocale('de'); + expect(getPathname('/about')).toBe('/de/about'); + }); + + it('adds a prefix when navigating to a secondary locale', () => { expect( getPathname({ href: '/about', @@ -549,6 +579,12 @@ describe("localePrefix: 'as-needed'", () => { expect(nextRedirectFn).toHaveBeenLastCalledWith('/'); }); + it('adds a prefix when currently on a secondary locale', () => { + mockCurrentLocale('de'); + runInRender(() => redirectFn('/')); + expect(nextRedirectFn).toHaveBeenLastCalledWith('/de'); + }); + it('forwards a redirect type', () => { runInRender(() => redirectFn('/', RedirectType.push)); expect(nextRedirectFn).toHaveBeenLastCalledWith('/', RedirectType.push); @@ -560,7 +596,7 @@ describe("localePrefix: 'as-needed'", () => { }); it('adds a prefix when redirecting from a different locale to the default locale', () => { - vi.mocked(getRequestLocale).mockImplementation(() => 'de'); + mockCurrentLocale('en'); runInRender(() => redirectFn({href: '/about', locale: 'en'})); expect(nextRedirectFn).toHaveBeenLastCalledWith('/en/about'); }); @@ -581,12 +617,18 @@ describe("localePrefix: 'never'", () => { }); describe('Link', () => { - it('renders no prefix for the default locale', () => { + it('renders no prefix when currently on the default locale', () => { const markup = renderToString(About); expect(markup).toContain('href="/about"'); }); - it('renders a prefix for a different locale', () => { + it('renders no prefix when currently on a secondary locale', () => { + mockCurrentLocale('de'); + const markup = renderToString(About); + expect(markup).toContain('href="/about"'); + }); + + it('renders a prefix when linking to a secondary locale', () => { const markup = renderToString( Über uns @@ -594,6 +636,16 @@ describe("localePrefix: 'never'", () => { ); expect(markup).toContain('href="/de/about"'); }); + + it('renders a prefix when currently on a secondary locale and linking to the default locale', () => { + mockCurrentLocale('de'); + const markup = renderToString( + + About + + ); + expect(markup).toContain('href="/en/about"'); + }); }); describe('getPathname', () => { @@ -601,6 +653,11 @@ describe("localePrefix: 'never'", () => { expect(getPathname('/unknown')).toBe('/unknown'); }); + it('does not add a prefix when currently on a secondary locale', () => { + mockCurrentLocale('de'); + expect(getPathname('/about')).toBe('/about'); + }); + it('does not add a prefix when specifying a secondary locale', () => { expect(getPathname({href: '/about', locale: 'de'})).toBe('/about'); }); diff --git a/packages/next-intl/src/navigation/react-server/createNavigation.tsx b/packages/next-intl/src/navigation/react-server/createNavigation.tsx index a4fab608e..52d00418d 100644 --- a/packages/next-intl/src/navigation/react-server/createNavigation.tsx +++ b/packages/next-intl/src/navigation/react-server/createNavigation.tsx @@ -140,7 +140,6 @@ export default function createNavigation< return applyPathnamePrefix({ pathname, locale, - curLocale: getCurrentLocale(), routing: config, force: forcePrefix }); diff --git a/packages/next-intl/src/navigation/shared/utils.tsx b/packages/next-intl/src/navigation/shared/utils.tsx index a376c29a8..5ceb5f8d0 100644 --- a/packages/next-intl/src/navigation/shared/utils.tsx +++ b/packages/next-intl/src/navigation/shared/utils.tsx @@ -215,8 +215,8 @@ export function getBasePath( export function applyPathnamePrefix(params: { pathname: string; locale: Locales[number]; - curLocale: Locales[number]; - routing: Pick, 'localePrefix' | 'domains'>; + routing: Pick, 'localePrefix' | 'domains'> & + Partial, 'defaultLocale'>>; force?: boolean; }): string { const {mode} = params.routing.localePrefix; @@ -225,7 +225,7 @@ export function applyPathnamePrefix(params: { (params.force || mode === 'always' || (mode === 'as-needed' && - params.locale !== params.curLocale && + params.routing.defaultLocale !== params.locale && !params.routing.domains)); return shouldPrefix From b3a81a27e768c722777d6e3f3326474ae41b1fef Mon Sep 17 00:00:00 2001 From: Jan Amann Date: Wed, 4 Sep 2024 16:58:33 +0200 Subject: [PATCH 07/62] =?UTF-8?q?got=20that=20initial=20render=20for=20lin?= =?UTF-8?q?k=20working=20=F0=9F=98=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...reateLocalizedPathnamesNavigation.test.tsx | 4 +- .../src/navigation/createNavigation.test.tsx | 169 ++++++++++-------- .../createSharedPathnamesNavigation.test.tsx | 4 +- .../navigation/react-client/ClientLink.tsx | 6 +- .../navigation/react-server/ServerLink.tsx | 6 +- .../react-server/createNavigation.tsx | 53 +++--- .../src/navigation/shared/BaseLink.tsx | 41 +---- .../src/navigation/shared/LegacyBaseLink.tsx | 56 ++++++ .../next-intl/src/navigation/shared/utils.tsx | 16 ++ packages/next-intl/src/shared/utils.tsx | 6 +- 10 files changed, 220 insertions(+), 141 deletions(-) create mode 100644 packages/next-intl/src/navigation/shared/LegacyBaseLink.tsx diff --git a/packages/next-intl/src/navigation/createLocalizedPathnamesNavigation.test.tsx b/packages/next-intl/src/navigation/createLocalizedPathnamesNavigation.test.tsx index a7a6fe9c7..d1e307eb8 100644 --- a/packages/next-intl/src/navigation/createLocalizedPathnamesNavigation.test.tsx +++ b/packages/next-intl/src/navigation/createLocalizedPathnamesNavigation.test.tsx @@ -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'); @@ -39,7 +39,7 @@ vi.mock('../../src/navigation/react-server/ServerLink', () => ({ const finalLocale = locale || 'en'; const prefix = getLocalePrefix(finalLocale, localePrefix); return ( - { const actual = await vi.importActual('next/navigation'); return { @@ -24,32 +23,12 @@ vi.mock('next/navigation', async () => { permanentRedirect: vi.fn() }; }); - vi.mock('next-intl/config', () => ({ default: async () => ((await vi.importActual('../../src/server')) as any).getRequestConfig({ locale: 'en' }) })); - -vi.mock('react'); - -// Avoids handling an async component (not supported by renderToString) -vi.mock('../../src/navigation/react-server/ServerLink', () => ({ - default({locale, localePrefix, ...rest}: any) { - const finalLocale = locale || nextUseParams().locale; - const prefix = getLocalePrefix(finalLocale, localePrefix); - return ( - - ); - } -})); - vi.mock('../../src/server/react-server/RequestLocale', () => ({ getRequestLocale: vi.fn(() => 'en') })); @@ -197,6 +176,10 @@ describe("localePrefix: 'always'", () => { // Still works .toBe('/en/about'); }); + + it('handles relative pathnames', () => { + expect(getPathname('about')).toBe('about'); + }); }); describe.each([ @@ -217,6 +200,11 @@ describe("localePrefix: 'always'", () => { runInRender(() => redirectFn({href: '/about', locale: 'de'})); expect(nextRedirectFn).toHaveBeenLastCalledWith('/de/about'); }); + + it('handles relative pathnames', () => { + runInRender(() => redirectFn('about')); + expect(nextRedirectFn).toHaveBeenLastCalledWith('about'); + }); }); }); @@ -250,11 +238,6 @@ describe("localePrefix: 'always', no `locales`", () => { ); expect(markup).toContain('href="/zh/about"'); }); - - it('handles relative links correctly on the initial render', () => { - const markup = renderToString(Test); - expect(markup).toContain('href="test"'); - }); }); describe('getPathname', () => { @@ -349,7 +332,7 @@ describe("localePrefix: 'always', with `pathnames`", () => { ); expect( screen.getByRole('link', {name: 'About'}).getAttribute('href') - ).toBe('/about?foo=bar'); + ).toBe('/en/about?foo=bar'); }); it('handles params', () => { @@ -380,6 +363,12 @@ describe("localePrefix: 'always', with `pathnames`", () => { // @ts-expect-error -- Missing params ; }); + + it('handles relative links', () => { + // @ts-expect-error -- Validation is still on + const markup = renderToString(Test); + expect(markup).toContain('href="test"'); + }); }); describe('getPathname', () => { @@ -423,6 +412,11 @@ describe("localePrefix: 'always', with `pathnames`", () => { // @ts-expect-error -- Unknown pathname expect(getPathname('/unknown')).toBe('/en/unknown'); }); + + it('handles relative pathnames', () => { + // @ts-expect-error -- Validation is still on + expect(getPathname('about')).toBe('about'); + }); }); describe.each([ @@ -484,60 +478,89 @@ describe("localePrefix: 'always', with `pathnames`", () => { runInRender(() => redirectFn('/', RedirectType.push)); expect(nextRedirectFn).toHaveBeenLastCalledWith('/en', RedirectType.push); }); + + it('can handle relative pathnames', () => { + // @ts-expect-error -- Validation is still on + runInRender(() => redirectFn('about')); + expect(nextRedirectFn).toHaveBeenLastCalledWith('about'); + }); }); }); describe("localePrefix: 'as-needed'", () => { - const {getPathname, permanentRedirect, redirect} = createNavigation({ + const {Link, getPathname, permanentRedirect, redirect} = createNavigation({ locales, defaultLocale, localePrefix: 'as-needed' }); - // describe.todo('Link', () => { - // // nooope. but only if we are already on that locale? for cookie switching - // // maybe: - // // - initial render without locale (correct link) - // // - add locale on client side to switch cookie? - // it.only('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'); - // }); - - // it('handles relative links correctly on the initial render', () => { - // const markup = renderToString(Test); - // expect(markup).toContain('href="test"'); - // }); - // }); + describe('createNavigation', () => { + it('errors when no `defaultLocale` is set', () => { + expect( + () => void createNavigation({localePrefix: 'as-needed'}) + ).toThrowError("`localePrefix: 'as-needed' requires a `defaultLocale`."); + }); + }); + + describe('Link', () => { + it('does not render a prefix when currently on the default locale', () => { + const markup = renderToString(About); + expect(markup).toContain('href="/about"'); + }); + + it('renders a prefix when currently on a secondary locale', () => { + mockCurrentLocale('de'); + const markup = renderToString(About); + expect(markup).toContain('href="/de/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'); + }); + + it('handles relative links correctly on the initial render', () => { + const markup = renderToString(Test); + expect(markup).toContain('href="test"'); + }); + + it('does not accept `params`', () => { + ; + }); + }); describe('getPathname', () => { it('does not add a prefix when the current locale is the default locale', () => { diff --git a/packages/next-intl/src/navigation/createSharedPathnamesNavigation.test.tsx b/packages/next-intl/src/navigation/createSharedPathnamesNavigation.test.tsx index e950e05bd..b54612181 100644 --- a/packages/next-intl/src/navigation/createSharedPathnamesNavigation.test.tsx +++ b/packages/next-intl/src/navigation/createSharedPathnamesNavigation.test.tsx @@ -14,7 +14,7 @@ import {getRequestLocale} from '../server/react-server/RequestLocale'; import {getLocalePrefix} from '../shared/utils'; import createSharedPathnamesNavigationClient from './react-client/createSharedPathnamesNavigation'; import createSharedPathnamesNavigationServer from './react-server/createSharedPathnamesNavigation'; -import BaseLink from './shared/BaseLink'; +import LegacyBaseLink from './shared/LegacyBaseLink'; vi.mock('next/navigation', async () => { const actual = await vi.importActual('next/navigation'); @@ -39,7 +39,7 @@ vi.mock('../../src/navigation/react-server/ServerLink', () => ({ const finalLocale = locale || 'en'; const prefix = getLocalePrefix(finalLocale, localePrefix); return ( - = Omit< - ComponentProps, + ComponentProps, 'locale' | 'prefix' | 'localePrefixMode' > & { locale?: AppLocales[number]; @@ -21,7 +21,7 @@ function ClientLink( const prefix = getLocalePrefix(finalLocale, localePrefix); return ( - = Omit< - ComponentProps, + ComponentProps, 'locale' | 'prefix' | 'localePrefixMode' > & { locale?: AppLocales[number]; @@ -21,7 +21,7 @@ export default async function ServerLink({ const prefix = getLocalePrefix(finalLocale, localePrefix); return ( - : ResolvedRoutingConfig; + if (process.env.NODE_ENV !== 'production') { + validateReceivedConfig(config); + } const pathnames = (config as any).pathnames as [AppPathnames] extends [never] ? undefined @@ -64,25 +67,35 @@ export default function createNavigation< locale, ...rest }: LinkProps) { - const curLocale = getCurrentLocale(); - const finalLocale = locale || curLocale; + let pathname, params; + if (typeof href === 'object') { + pathname = href.pathname; + // @ts-expect-error -- This is ok + params = href.params; + } else { + pathname = href; + } + + // @ts-expect-error -- This is ok + const finalPathname = isLocalizableHref(href) + ? getPathname( + // @ts-expect-error -- This is ok + { + locale, + href: pathnames == null ? pathname : {pathname, params} + }, + locale != null + ) + : pathname; return ( - ({ - locale: finalLocale, - // @ts-expect-error -- This is ok - pathname: href, - // @ts-expect-error -- This is ok - params: typeof href === 'object' ? href.params : undefined, - pathnames - }) - : (href as string) - } + ); diff --git a/packages/next-intl/src/navigation/shared/BaseLink.tsx b/packages/next-intl/src/navigation/shared/BaseLink.tsx index 1d5fafb2f..fb1b81ff2 100644 --- a/packages/next-intl/src/navigation/shared/BaseLink.tsx +++ b/packages/next-intl/src/navigation/shared/BaseLink.tsx @@ -2,26 +2,16 @@ import NextLink from 'next/link'; import {usePathname} from 'next/navigation'; -import React, { - ComponentProps, - MouseEvent, - forwardRef, - useEffect, - useState -} from 'react'; +import React, {ComponentProps, MouseEvent, forwardRef} from 'react'; import useLocale from '../../react-client/useLocale'; -import {LocalePrefixMode} from '../../routing/types'; -import {isLocalizableHref, localizeHref, prefixHref} from '../../shared/utils'; import syncLocaleCookie from './syncLocaleCookie'; type Props = Omit, 'locale'> & { - locale: string; - prefix: string; - localePrefixMode: LocalePrefixMode; + locale?: string; }; function BaseLink( - {href, locale, localePrefixMode, onClick, prefetch, prefix, ...rest}: Props, + {href, locale, onClick, prefetch, ...rest}: Props, ref: Props['ref'] ) { // The types aren't entirely correct here. Outside of Next.js @@ -31,34 +21,11 @@ function BaseLink( const curLocale = useLocale(); const isChangingLocale = locale !== curLocale; - const [localizedHref, setLocalizedHref] = useState(() => - isLocalizableHref(href) && - (localePrefixMode !== '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. 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, prefix) - : href - ); - function onLinkClick(event: MouseEvent) { syncLocaleCookie(pathname, curLocale, locale); if (onClick) onClick(event); } - useEffect(() => { - if (!pathname) return; - - setLocalizedHref(localizeHref(href, locale, curLocale, pathname, prefix)); - }, [curLocale, href, locale, pathname, prefix]); - if (isChangingLocale) { if (prefetch && process.env.NODE_ENV !== 'production') { console.error( @@ -71,7 +38,7 @@ function BaseLink( return ( , 'locale'> & { + locale: string; + prefix: string; + localePrefixMode: LocalePrefixMode; +}; + +function LegacyBaseLink( + {href, locale, localePrefixMode, prefix, ...rest}: Props, + ref: Props['ref'] +) { + // The types aren't entirely correct here. Outside of Next.js + // `useParams` can be called, but the return type is `null`. + const pathname = usePathname() as ReturnType | null; + + const curLocale = useLocale(); + const isChangingLocale = locale !== curLocale; + + const [localizedHref, setLocalizedHref] = useState(() => + isLocalizableHref(href) && + (localePrefixMode !== '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. 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, prefix) + : href + ); + + useEffect(() => { + if (!pathname) return; + + setLocalizedHref(localizeHref(href, locale, curLocale, pathname, prefix)); + }, [curLocale, href, locale, pathname, prefix]); + + return ; +} + +const LegacyBaseLinkWithRef = forwardRef(LegacyBaseLink); +(LegacyBaseLinkWithRef as any).displayName = 'ClientLink'; +export default LegacyBaseLinkWithRef; diff --git a/packages/next-intl/src/navigation/shared/utils.tsx b/packages/next-intl/src/navigation/shared/utils.tsx index 5ceb5f8d0..c008e15d0 100644 --- a/packages/next-intl/src/navigation/shared/utils.tsx +++ b/packages/next-intl/src/navigation/shared/utils.tsx @@ -235,3 +235,19 @@ export function applyPathnamePrefix(params: { ) : params.pathname; } + +export function validateReceivedConfig( + config: Partial< + Pick< + ResolvedRoutingConfig>, + 'defaultLocale' | 'localePrefix' + > + > +) { + if ( + config.localePrefix?.mode === 'as-needed' && + !('defaultLocale' in config) + ) { + throw new Error("`localePrefix: 'as-needed' requires a `defaultLocale`."); + } +} diff --git a/packages/next-intl/src/shared/utils.tsx b/packages/next-intl/src/shared/utils.tsx index 68adac5b0..0a9693538 100644 --- a/packages/next-intl/src/shared/utils.tsx +++ b/packages/next-intl/src/shared/utils.tsx @@ -5,9 +5,13 @@ import {Locales, LocalePrefixConfigVerbose} from '../routing/types'; type Href = ComponentProps['href']; +function isRelativePathname(pathname?: string | null) { + return pathname != null && !pathname.startsWith('/'); +} + function isRelativeHref(href: Href) { const pathname = typeof href === 'object' ? href.pathname : href; - return pathname != null && !pathname.startsWith('/'); + return isRelativePathname(pathname); } function isLocalHref(href: Href) { From 00fb58f7cab74c37f3c11a5b69cb9418fcbd38a7 Mon Sep 17 00:00:00 2001 From: Jan Amann Date: Wed, 4 Sep 2024 17:56:26 +0200 Subject: [PATCH 08/62] Some cleanup --- .../src/navigation/createNavigation.test.tsx | 31 ++++++++++++++----- .../react-client/ClientLink.test.tsx | 3 ++ .../navigation/react-server/ServerLink.tsx | 2 ++ .../react-server/createNavigation.tsx | 14 ++++----- .../next-intl/src/navigation/shared/utils.tsx | 2 ++ 5 files changed, 37 insertions(+), 15 deletions(-) diff --git a/packages/next-intl/src/navigation/createNavigation.test.tsx b/packages/next-intl/src/navigation/createNavigation.test.tsx index 5a14b884f..1f03259f1 100644 --- a/packages/next-intl/src/navigation/createNavigation.test.tsx +++ b/packages/next-intl/src/navigation/createNavigation.test.tsx @@ -108,6 +108,7 @@ describe("localePrefix: 'always'", () => { ); expect(markup).toContain('href="/de/about"'); + expect(markup).toContain('hrefLang="de"'); }); it('renders an object href', () => { @@ -134,11 +135,18 @@ describe("localePrefix: 'always'", () => { ).toBe('/de/news/launch-party-3'); }); - it('handles relative links correctly on the initial render', () => { + it('handles relative links correctly', () => { const markup = renderToString(Test); expect(markup).toContain('href="test"'); }); + it('handles external links correctly', () => { + const markup = renderToString( + Test + ); + expect(markup).toContain('href="https://example.com/test"'); + }); + it('does not allow to use unknown locales', () => { const markup = renderToString( // @ts-expect-error -- Unknown locale @@ -355,6 +363,20 @@ describe("localePrefix: 'always', with `pathnames`", () => { ).toBe('/de/neuigkeiten/launch-party-3'); }); + it('handles relative links', () => { + // @ts-expect-error -- Validation is still on + const markup = renderToString(Test); + expect(markup).toContain('href="test"'); + }); + + it('handles external links correctly', () => { + const markup = renderToString( + // @ts-expect-error -- Validation is still on + Test + ); + expect(markup).toContain('href="https://example.com/test"'); + }); + it('restricts invalid usage', () => { // @ts-expect-error -- Unknown locale ; @@ -363,12 +385,6 @@ describe("localePrefix: 'always', with `pathnames`", () => { // @ts-expect-error -- Missing params ; }); - - it('handles relative links', () => { - // @ts-expect-error -- Validation is still on - const markup = renderToString(Test); - expect(markup).toContain('href="test"'); - }); }); describe('getPathname', () => { @@ -658,6 +674,7 @@ describe("localePrefix: 'never'", () => { ); expect(markup).toContain('href="/de/about"'); + expect(markup).toContain('hrefLang="de"'); }); it('renders a prefix when currently on a secondary locale and linking to the default locale', () => { diff --git a/packages/next-intl/src/navigation/react-client/ClientLink.test.tsx b/packages/next-intl/src/navigation/react-client/ClientLink.test.tsx index bf4e3dbf7..19d14172b 100644 --- a/packages/next-intl/src/navigation/react-client/ClientLink.test.tsx +++ b/packages/next-intl/src/navigation/react-client/ClientLink.test.tsx @@ -6,6 +6,9 @@ import {NextIntlClientProvider} from '../../index.react-client'; import {LocalePrefixConfigVerbose} from '../../routing/types'; import ClientLink from './ClientLink'; +// Note: Once we remove the legacy navigation APIs, this test suite can be +// removed too. All relevant tests have been moved to the new navigation API. + vi.mock('next/navigation'); function mockLocation(pathname: string, basePath = '') { diff --git a/packages/next-intl/src/navigation/react-server/ServerLink.tsx b/packages/next-intl/src/navigation/react-server/ServerLink.tsx index e60f75f1c..612c95870 100644 --- a/packages/next-intl/src/navigation/react-server/ServerLink.tsx +++ b/packages/next-intl/src/navigation/react-server/ServerLink.tsx @@ -4,6 +4,8 @@ import {getLocale} from '../../server.react-server'; import {getLocalePrefix} from '../../shared/utils'; import LegacyBaseLink from '../shared/LegacyBaseLink'; +// Only used by legacy navigation APIs, can be removed when they are removed + type Props = Omit< ComponentProps, 'locale' | 'prefix' | 'localePrefixMode' diff --git a/packages/next-intl/src/navigation/react-server/createNavigation.tsx b/packages/next-intl/src/navigation/react-server/createNavigation.tsx index ff9284db1..43193ee99 100644 --- a/packages/next-intl/src/navigation/react-server/createNavigation.tsx +++ b/packages/next-intl/src/navigation/react-server/createNavigation.tsx @@ -22,7 +22,6 @@ import { normalizeNameOrNameWithParams, validateReceivedConfig } from '../shared/utils'; -import ServerLink from './ServerLink'; export default function createNavigation< const AppLocales extends Locales, @@ -54,11 +53,11 @@ export default function createNavigation< } type LinkProps = Omit< - ComponentProps, + ComponentProps, 'href' | 'localePrefix' > & { href: [AppPathnames] extends [never] - ? ComponentProps['href'] + ? ComponentProps['href'] : HrefOrUrlObjectWithParams; locale?: Locale; }; @@ -101,7 +100,6 @@ export default function createNavigation< ); } - // TODO: Should this be called in Link? Maybe not, we can hydrate for one case there. Or: Call it with localePrefix: 'always' and again on the client side? // New: Locale is now optional (do we want this?) // New: accepts plain href argument // New: getPathname is available for shared pathnames @@ -115,7 +113,8 @@ export default function createNavigation< href: HrefOrHrefWithParams; }, /** @private */ - forcePrefix?: boolean + _forcePrefix?: boolean + // TODO: Should we somehow ensure this doesn't get emitted to the types? ) { let hrefArg: [AppPathnames] extends [never] ? string @@ -145,8 +144,7 @@ export default function createNavigation< }); } - // TODO: There might be only one shot here, for as-necessary - // and domains, should we apply the prefix here? Alternative + // TODO: There might be only one shot here, for as-needed // would be reading `host`, but that breaks SSG. If you want // to get the first shot right, pass a `domain` here (then // the user opts into dynamic rendering) @@ -154,7 +152,7 @@ export default function createNavigation< pathname, locale, routing: config, - force: forcePrefix + force: _forcePrefix }); } diff --git a/packages/next-intl/src/navigation/shared/utils.tsx b/packages/next-intl/src/navigation/shared/utils.tsx index c008e15d0..fd59b3216 100644 --- a/packages/next-intl/src/navigation/shared/utils.tsx +++ b/packages/next-intl/src/navigation/shared/utils.tsx @@ -115,6 +115,7 @@ export function compileLocalizedPathname({ function getNamedPath(value: keyof typeof pathnames) { let namedPath = pathnames[value]; if (!namedPath) { + // Unknown pathnames namedPath = value; } return namedPath; @@ -226,6 +227,7 @@ export function applyPathnamePrefix(params: { mode === 'always' || (mode === 'as-needed' && params.routing.defaultLocale !== params.locale && + // TODO: Rework !params.routing.domains)); return shouldPrefix From 690fe46be0300355d5af676012f1968864f11cc7 Mon Sep 17 00:00:00 2001 From: Jan Amann Date: Thu, 5 Sep 2024 10:30:19 +0200 Subject: [PATCH 09/62] Add another test --- .../next-intl/src/navigation/createNavigation.test.tsx | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/packages/next-intl/src/navigation/createNavigation.test.tsx b/packages/next-intl/src/navigation/createNavigation.test.tsx index 1f03259f1..d7ab2040d 100644 --- a/packages/next-intl/src/navigation/createNavigation.test.tsx +++ b/packages/next-intl/src/navigation/createNavigation.test.tsx @@ -539,6 +539,16 @@ describe("localePrefix: 'as-needed'", () => { expect(markup).toContain('href="/de/about"'); }); + it('renders a prefix when currently on a secondary locale and linking to the default locale', () => { + mockCurrentLocale('de'); + const markup = renderToString( + + About + + ); + expect(markup).toContain('href="/en/about"'); + }); + it('renders an object href', () => { render( About From 4660545dc5b102b8fc8279f9ae943f028eb56c9b Mon Sep 17 00:00:00 2001 From: Jan Amann Date: Thu, 5 Sep 2024 12:49:57 +0200 Subject: [PATCH 10/62] Mandatory locale for getPathname, don't allow to pass a locale to redirect --- .../src/navigation/createNavigation.test.tsx | 212 ++++++++---------- .../react-server/createNavigation.tsx | 64 +++--- .../next-intl/src/navigation/shared/utils.tsx | 3 +- 3 files changed, 121 insertions(+), 158 deletions(-) diff --git a/packages/next-intl/src/navigation/createNavigation.test.tsx b/packages/next-intl/src/navigation/createNavigation.test.tsx index d7ab2040d..5c67488a8 100644 --- a/packages/next-intl/src/navigation/createNavigation.test.tsx +++ b/packages/next-intl/src/navigation/createNavigation.test.tsx @@ -160,33 +160,62 @@ describe("localePrefix: 'always'", () => { }); describe('getPathname', () => { - it('can be called with an arbitrary pathname', () => { - expect(getPathname('/unknown')).toBe('/en/unknown'); + it('can be called for the default locale', () => { + expect(getPathname({href: '/unknown', locale: 'en'})).toBe('/en/unknown'); }); - it('adds a prefix when currently on a secondary locale', () => { - mockCurrentLocale('de'); - expect(getPathname('/about')).toBe('/de/about'); + it('can be called for a secondary locale', () => { + expect(getPathname({locale: 'de', href: '/about'})).toBe('/de/about'); }); - it('can switch the locale while providing an `href`', () => { + it('can incorporate query params', () => { expect( getPathname({ - href: '/about', - locale: 'de' + href: { + pathname: '/about', + query: {foo: 'bar'} + }, + locale: 'en' }) - ).toBe('/de/about'); + ).toBe('/en/about?foo=bar'); }); - it('requires a locale when using an object href', () => { - // @ts-expect-error -- Missing locale - expect(getPathname({href: '/about'})) - // Still works - .toBe('/en/about'); + it('does not accept `query` on the root', () => { + // eslint-disable-next-line no-unused-expressions + () => + getPathname({ + href: '/about', + locale: 'en', + // @ts-expect-error -- Not allowed + query: {foo: 'bar'} + }); }); - it('handles relative pathnames', () => { - expect(getPathname('about')).toBe('about'); + it('does not accept `params` on href', () => { + // eslint-disable-next-line no-unused-expressions + () => + getPathname({ + href: { + pathname: '/users/[userId]', + // @ts-expect-error -- Not allowed + params: {userId: 3} + }, + locale: 'en' + }); + }); + + it('requires a locale', () => { + // Some background: This function can be used either in the `react-server` + // or the `react-client` environment. Since the function signature doesn't + // impose a limit on where it can be called (e.g. during rendering), we + // can't determine the current locale in the `react-client` environment. + // While we could theoretically retrieve the current locale in the + // `react-server` environment we need a shared function signature that + // works in either environment. + + // @ts-expect-error -- Missing locale + // eslint-disable-next-line no-unused-expressions + () => getPathname({href: '/about'}); }); }); @@ -204,15 +233,25 @@ describe("localePrefix: 'always'", () => { expect(nextRedirectFn).toHaveBeenLastCalledWith('/en', RedirectType.push); }); - it('can redirect for a different locale', () => { - runInRender(() => redirectFn({href: '/about', locale: 'de'})); - expect(nextRedirectFn).toHaveBeenLastCalledWith('/de/about'); + // There's nothing strictly against this, but there was no need for this so + // far. The API design is a bit tricky since Next.js uses the second argument + // for a plain `type` string. Should we support an object here? Also consider + // API symmetry with `router.push`. + it('can not redirect for a different locale', () => { + // @ts-expect-error + // eslint-disable-next-line no-unused-expressions + () => redirectFn('/about', {locale: 'de'}); }); it('handles relative pathnames', () => { runInRender(() => redirectFn('about')); expect(nextRedirectFn).toHaveBeenLastCalledWith('about'); }); + + it('handles query params', () => { + runInRender(() => redirectFn({pathname: '/about', query: {foo: 'bar'}})); + expect(nextRedirectFn).toHaveBeenLastCalledWith('/en/about?foo=bar'); + }); }); }); @@ -249,11 +288,11 @@ describe("localePrefix: 'always', no `locales`", () => { }); describe('getPathname', () => { - it('adds a prefix for the current locale', () => { - expect(getPathname('/about')).toBe('/en/about'); + it('adds a prefix for the default locale', () => { + expect(getPathname({href: '/about', locale: 'en'})).toBe('/en/about'); }); - it('adds a prefix for a different locale', () => { + it('adds a prefix for a secondary locale', () => { expect(getPathname({href: '/about', locale: 'zh'})).toBe('/zh/about'); }); }); @@ -266,11 +305,6 @@ describe("localePrefix: 'always', no `locales`", () => { runInRender(() => redirectFn('/')); expect(nextRedirectFn).toHaveBeenLastCalledWith('/en'); }); - - it('can redirect for a different locale', () => { - runInRender(() => redirectFn({href: '/about', locale: 'de'})); - expect(nextRedirectFn).toHaveBeenLastCalledWith('/de/about'); - }); }); }); @@ -389,28 +423,19 @@ describe("localePrefix: 'always', with `pathnames`", () => { describe('getPathname', () => { it('can be called with a known pathname', () => { - expect(getPathname('/about')).toBe('/en/about'); - expect(getPathname({pathname: '/about', query: {foo: 'bar'}})).toBe( - '/en/about?foo=bar' - ); - }); - - it('can resolve a pathname with params for the current locale via a short-hand', () => { + expect(getPathname({href: '/about', locale: 'en'})).toBe('/en/about'); expect( getPathname({ - pathname: '/news/[articleSlug]-[articleId]', - params: { - articleId: 3, - articleSlug: 'launch-party' - }, - query: {foo: 'bar'} + href: {pathname: '/about', query: {foo: 'bar'}}, + locale: 'en' }) - ).toBe('/en/news/launch-party-3?foo=bar'); + ).toBe('/en/about?foo=bar'); }); - it('can switch the locale while providing an `href`', () => { + it('can resolve a pathname with params', () => { expect( getPathname({ + locale: 'en', href: { pathname: '/news/[articleSlug]-[articleId]', params: { @@ -418,20 +443,21 @@ describe("localePrefix: 'always', with `pathnames`", () => { articleSlug: 'launch-party' }, query: {foo: 'bar'} - }, - locale: 'de' + } }) - ).toBe('/de/neuigkeiten/launch-party-3?foo=bar'); + ).toBe('/en/news/launch-party-3?foo=bar'); }); it('can not be called with an arbitrary pathname', () => { // @ts-expect-error -- Unknown pathname - expect(getPathname('/unknown')).toBe('/en/unknown'); + expect(getPathname({locale: 'en', href: '/unknown'})) + // Works regardless + .toBe('/en/unknown'); }); it('handles relative pathnames', () => { // @ts-expect-error -- Validation is still on - expect(getPathname('about')).toBe('about'); + expect(getPathname({locale: 'en', href: 'about'})).toBe('about'); }); }); @@ -444,7 +470,7 @@ describe("localePrefix: 'always', with `pathnames`", () => { expect(nextRedirectFn).toHaveBeenLastCalledWith('/en'); }); - it('can redirect with params', () => { + it('can redirect with params and query params', () => { runInRender(() => redirectFn({ pathname: '/news/[articleSlug]-[articleId]', @@ -460,33 +486,10 @@ describe("localePrefix: 'always', with `pathnames`", () => { ); }); - it('can redirect for a different locale', () => { - runInRender(() => redirectFn({href: '/about', locale: 'de'})); - expect(nextRedirectFn).toHaveBeenLastCalledWith('/de/ueber-uns'); - }); - - it('can redirect for a different locale with params', () => { - runInRender(() => - redirectFn({ - href: { - pathname: '/news/[articleSlug]-[articleId]', - params: { - articleId: 3, - articleSlug: 'launch-party' - }, - query: {foo: 'bar'} - }, - locale: 'de' - }) - ); - expect(nextRedirectFn).toHaveBeenLastCalledWith( - '/de/neuigkeiten/launch-party-3?foo=bar' - ); - }); - it('can not be called with an arbitrary pathname', () => { // @ts-expect-error -- Unknown pathname runInRender(() => redirectFn('/unknown')); + // Works regardless expect(nextRedirectFn).toHaveBeenLastCalledWith('/en/unknown'); }); @@ -589,33 +592,18 @@ describe("localePrefix: 'as-needed'", () => { }); describe('getPathname', () => { - it('does not add a prefix when the current locale is the default locale', () => { - expect(getPathname('/unknown')).toBe('/unknown'); - }); - - it('adds a prefix when currently on a secondary locale', () => { - mockCurrentLocale('de'); - expect(getPathname('/about')).toBe('/de/about'); + it('does not add a prefix for the default locale', () => { + expect(getPathname({locale: 'en', href: '/about'})).toBe('/about'); }); - it('adds a prefix when navigating to a secondary locale', () => { - expect( - getPathname({ - href: '/about', - locale: 'de' - }) - ).toBe('/de/about'); + it('adds a prefix for a secondary locale', () => { + expect(getPathname({locale: 'de', href: '/about'})).toBe('/de/about'); }); - it('requires a locale when using an object href', () => { + it('requires a locale', () => { // @ts-expect-error -- Missing locale - expect(getPathname({href: '/about'})) - // Still works - .toBe('/about'); - }); - - it('does not add a prefix for the default locale', () => { - expect(getPathname({href: '/about', locale: 'en'})).toBe('/about'); + // eslint-disable-next-line no-unused-expressions + () => getPathname({href: '/about'}); }); }); @@ -638,17 +626,6 @@ describe("localePrefix: 'as-needed'", () => { runInRender(() => redirectFn('/', RedirectType.push)); expect(nextRedirectFn).toHaveBeenLastCalledWith('/', RedirectType.push); }); - - it('adds a prefix when redirecting to a secondary locale', () => { - runInRender(() => redirectFn({href: '/about', locale: 'de'})); - expect(nextRedirectFn).toHaveBeenLastCalledWith('/de/about'); - }); - - it('adds a prefix when redirecting from a different locale to the default locale', () => { - mockCurrentLocale('en'); - runInRender(() => redirectFn({href: '/about', locale: 'en'})); - expect(nextRedirectFn).toHaveBeenLastCalledWith('/en/about'); - }); }); }); @@ -699,24 +676,18 @@ describe("localePrefix: 'never'", () => { }); describe('getPathname', () => { - it('does not add a prefix when staying on the current locale', () => { - expect(getPathname('/unknown')).toBe('/unknown'); - }); - - it('does not add a prefix when currently on a secondary locale', () => { - mockCurrentLocale('de'); - expect(getPathname('/about')).toBe('/about'); + it('does not add a prefix for the default locale', () => { + expect(getPathname({locale: 'en', href: '/unknown'})).toBe('/unknown'); }); - it('does not add a prefix when specifying a secondary locale', () => { - expect(getPathname({href: '/about', locale: 'de'})).toBe('/about'); + it('does not add a prefix for a secondary locale', () => { + expect(getPathname({locale: 'de', href: '/about'})).toBe('/about'); }); - it('requires a locale when using an object href', () => { + it('requires a locale', () => { // @ts-expect-error -- Missing locale - expect(getPathname({href: '/about'})) - // Still works - .toBe('/about'); + // eslint-disable-next-line no-unused-expressions + () => getPathname({href: '/about'}); }); }); @@ -733,10 +704,5 @@ describe("localePrefix: 'never'", () => { runInRender(() => redirectFn('/', RedirectType.push)); expect(nextRedirectFn).toHaveBeenLastCalledWith('/', RedirectType.push); }); - - it('can redirect for a different locale', () => { - runInRender(() => redirectFn({href: '/about', locale: 'de'})); - expect(nextRedirectFn).toHaveBeenLastCalledWith('/de/about'); - }); }); }); diff --git a/packages/next-intl/src/navigation/react-server/createNavigation.tsx b/packages/next-intl/src/navigation/react-server/createNavigation.tsx index 43193ee99..8c497e6ae 100644 --- a/packages/next-intl/src/navigation/react-server/createNavigation.tsx +++ b/packages/next-intl/src/navigation/react-server/createNavigation.tsx @@ -17,9 +17,11 @@ import BaseLink from '../shared/BaseLink'; import { HrefOrHrefWithParams, HrefOrUrlObjectWithParams, + QueryParams, applyPathnamePrefix, compileLocalizedPathname, normalizeNameOrNameWithParams, + serializeSearchParams, validateReceivedConfig } from '../shared/utils'; @@ -75,12 +77,14 @@ export default function createNavigation< pathname = href; } + const curLocale = getCurrentLocale(); + // @ts-expect-error -- This is ok const finalPathname = isLocalizableHref(href) ? getPathname( - // @ts-expect-error -- This is ok { - locale, + locale: locale || curLocale, + // @ts-expect-error -- This is ok href: pathnames == null ? pathname : {pathname, params} }, locale != null @@ -100,45 +104,36 @@ export default function createNavigation< ); } - // New: Locale is now optional (do we want this?) - // New: accepts plain href argument // New: getPathname is available for shared pathnames function getPathname( - href: [AppPathnames] extends [never] - ? string | {locale: Locale; href: string} - : - | HrefOrHrefWithParams - | { - locale: Locale; - href: HrefOrHrefWithParams; - }, + { + href, + locale + }: { + locale: Locale; + href: [AppPathnames] extends [never] + ? string | {pathname: string; query?: QueryParams} + : HrefOrHrefWithParams; + }, /** @private */ _forcePrefix?: boolean // TODO: Should we somehow ensure this doesn't get emitted to the types? ) { - let hrefArg: [AppPathnames] extends [never] - ? string - : HrefOrHrefWithParams; - let locale; - if (typeof href === 'object' && 'locale' in href) { - locale = href.locale; - // @ts-expect-error -- This is implied - hrefArg = href.href; - } else { - hrefArg = href as typeof hrefArg; - } - - if (!locale) locale = getCurrentLocale(); - let pathname: string; if (pathnames == null) { - // @ts-expect-error -- This is ok - pathname = typeof href === 'string' ? href : href.href; + if (typeof href === 'object') { + pathname = href.pathname as string; + if (href.query) { + pathname += serializeSearchParams(href.query); + } + } else { + pathname = href as string; + } } else { pathname = compileLocalizedPathname({ locale, // @ts-expect-error -- This is ok - ...normalizeNameOrNameWithParams(hrefArg), + ...normalizeNameOrNameWithParams(href), // @ts-expect-error -- This is ok pathnames: config.pathnames }); @@ -158,22 +153,23 @@ export default function createNavigation< function baseRedirect( fn: typeof nextRedirect | typeof nextPermanentRedirect, - href: Parameters[0], + href: Parameters[0]['href'], ...args: ParametersExceptFirst ) { + const locale = getCurrentLocale(); const isChangingLocale = typeof href === 'object' && 'locale' in href; - return fn(getPathname(href, isChangingLocale), ...args); + return fn(getPathname({href, locale}, isChangingLocale), ...args); } function redirect( - href: Parameters[0], + href: Parameters[0]['href'], ...args: ParametersExceptFirst ) { return baseRedirect(nextRedirect, href, ...args); } function permanentRedirect( - href: Parameters[0], + href: Parameters[0]['href'], ...args: ParametersExceptFirst ) { return baseRedirect(nextPermanentRedirect, href, ...args); @@ -182,7 +178,7 @@ export default function createNavigation< function notSupported(hookName: string) { return () => { throw new Error( - `\`${hookName}\` is not supported in Server Components. You can use this hook if you convert the component to a Client Component.` + `\`${hookName}\` is not supported in Server Components. You can use this hook if you convert the calling component to a Client Component.` ); }; } diff --git a/packages/next-intl/src/navigation/shared/utils.tsx b/packages/next-intl/src/navigation/shared/utils.tsx index fd59b3216..93046cab9 100644 --- a/packages/next-intl/src/navigation/shared/utils.tsx +++ b/packages/next-intl/src/navigation/shared/utils.tsx @@ -31,9 +31,10 @@ export type HrefOrUrlObjectWithParams = HrefOrHrefWithParamsImpl< Omit >; +export type QueryParams = Record; export type HrefOrHrefWithParams = HrefOrHrefWithParamsImpl< Pathname, - {query?: Record} + {query?: QueryParams} >; export function normalizeNameOrNameWithParams( From 5a6f7a1848572779619415f3deaa72da5bb437b6 Mon Sep 17 00:00:00 2001 From: Jan Amann Date: Thu, 5 Sep 2024 13:27:46 +0200 Subject: [PATCH 11/62] initial react-client implementation --- .../src/navigation/createNavigation.test.tsx | 1076 +++++++++-------- .../react-client/createNavigation.tsx | 39 +- .../react-server/createNavigation.test.tsx | 26 + .../react-server/createNavigation.tsx | 164 +-- .../shared/createSharedNavigationFns.tsx | 180 +++ 5 files changed, 800 insertions(+), 685 deletions(-) create mode 100644 packages/next-intl/src/navigation/react-server/createNavigation.test.tsx create mode 100644 packages/next-intl/src/navigation/shared/createSharedNavigationFns.tsx diff --git a/packages/next-intl/src/navigation/createNavigation.test.tsx b/packages/next-intl/src/navigation/createNavigation.test.tsx index 5c67488a8..58fa718a3 100644 --- a/packages/next-intl/src/navigation/createNavigation.test.tsx +++ b/packages/next-intl/src/navigation/createNavigation.test.tsx @@ -10,7 +10,8 @@ import {renderToString} from 'react-dom/server'; import {it, describe, vi, expect, beforeEach} from 'vitest'; import {defineRouting, Pathnames} from '../routing'; import {getRequestLocale} from '../server/react-server/RequestLocale'; -import createNavigation from './react-server/createNavigation'; +import createNavigationClient from './react-client/createNavigation'; +import createNavigationServer from './react-server/createNavigation'; vi.mock('react'); vi.mock('next/navigation', async () => { @@ -75,634 +76,661 @@ function runInRender(cb: () => void) { render(); } -describe("localePrefix: 'always'", () => { - const {Link, getPathname, permanentRedirect, redirect} = createNavigation({ - locales, - defaultLocale, - localePrefix: 'always' - }); +describe.each([ + { + env: 'react-client', + implementation: createNavigationClient + }, + { + env: 'react-server', + implementation: createNavigationServer + } +])('createNavigation ($env)', ({implementation: createNavigation}) => { + describe("localePrefix: 'always'", () => { + const {Link, getPathname, permanentRedirect, redirect} = createNavigation({ + locales, + defaultLocale, + localePrefix: 'always' + }); + + describe('Link', () => { + it('renders a prefix when currently on the default locale', () => { + const markup = renderToString(About); + expect(markup).toContain('href="/en/about"'); + }); - describe('Link', () => { - it('renders a prefix when currently on the default locale', () => { - const markup = renderToString(About); - expect(markup).toContain('href="/en/about"'); - }); + it('renders a prefix when currently on a secondary locale', () => { + mockCurrentLocale('de'); + const markup = renderToString(About); + expect(markup).toContain('href="/de/about"'); + }); - it('renders a prefix when currently on a secondary locale', () => { - mockCurrentLocale('de'); - const markup = renderToString(About); - expect(markup).toContain('href="/de/about"'); - }); + it('accepts query params', () => { + const markup = renderToString( + About + ); + expect(markup).toContain('href="/en/about?foo=bar"'); + }); - it('accepts query params', () => { - const markup = renderToString( - About - ); - expect(markup).toContain('href="/en/about?foo=bar"'); - }); + it('renders a prefix for a different locale', () => { + const markup = renderToString( + + Über uns + + ); + expect(markup).toContain('href="/de/about"'); + expect(markup).toContain('hrefLang="de"'); + }); - it('renders a prefix for a different locale', () => { - const markup = renderToString( - - Über uns - - ); - expect(markup).toContain('href="/de/about"'); - expect(markup).toContain('hrefLang="de"'); - }); + it('renders an object href', () => { + render( + + About + + ); + expect( + screen.getByRole('link', {name: 'About'}).getAttribute('href') + ).toBe('//www.test.de/about?foo=bar'); + }); - it('renders an object href', () => { - render( - - About - - ); - expect( - screen.getByRole('link', {name: 'About'}).getAttribute('href') - ).toBe('//www.test.de/about?foo=bar'); - }); + it('handles params', () => { + render( + + About + + ); + expect( + screen.getByRole('link', {name: 'About'}).getAttribute('href') + ).toBe('/de/news/launch-party-3'); + }); - it('handles params', () => { - render( - - About - - ); - expect( - screen.getByRole('link', {name: 'About'}).getAttribute('href') - ).toBe('/de/news/launch-party-3'); - }); + it('handles relative links correctly', () => { + const markup = renderToString(Test); + expect(markup).toContain('href="test"'); + }); - it('handles relative links correctly', () => { - const markup = renderToString(Test); - expect(markup).toContain('href="test"'); - }); + it('handles external links correctly', () => { + const markup = renderToString( + Test + ); + expect(markup).toContain('href="https://example.com/test"'); + }); - it('handles external links correctly', () => { - const markup = renderToString( - Test - ); - expect(markup).toContain('href="https://example.com/test"'); + it('does not allow to use unknown locales', () => { + const markup = renderToString( + // @ts-expect-error -- Unknown locale + + Unknown + + ); + // Still works + expect(markup).toContain('href="/zh/about"'); + }); }); - it('does not allow to use unknown locales', () => { - const markup = renderToString( - // @ts-expect-error -- Unknown locale - - Unknown - - ); - // Still works - expect(markup).toContain('href="/zh/about"'); - }); - }); + describe('getPathname', () => { + it('can be called for the default locale', () => { + expect(getPathname({href: '/unknown', locale: 'en'})).toBe( + '/en/unknown' + ); + }); - describe('getPathname', () => { - it('can be called for the default locale', () => { - expect(getPathname({href: '/unknown', locale: 'en'})).toBe('/en/unknown'); - }); + it('can be called for a secondary locale', () => { + expect(getPathname({locale: 'de', href: '/about'})).toBe('/de/about'); + }); - it('can be called for a secondary locale', () => { - expect(getPathname({locale: 'de', href: '/about'})).toBe('/de/about'); - }); + it('can incorporate query params', () => { + expect( + getPathname({ + href: { + pathname: '/about', + query: {foo: 'bar'} + }, + locale: 'en' + }) + ).toBe('/en/about?foo=bar'); + }); - it('can incorporate query params', () => { - expect( - getPathname({ - href: { - pathname: '/about', + it('does not accept `query` on the root', () => { + // eslint-disable-next-line no-unused-expressions + () => + getPathname({ + href: '/about', + locale: 'en', + // @ts-expect-error -- Not allowed query: {foo: 'bar'} - }, - locale: 'en' - }) - ).toBe('/en/about?foo=bar'); - }); + }); + }); - it('does not accept `query` on the root', () => { - // eslint-disable-next-line no-unused-expressions - () => - getPathname({ - href: '/about', - locale: 'en', - // @ts-expect-error -- Not allowed - query: {foo: 'bar'} - }); - }); + it('does not accept `params` on href', () => { + // eslint-disable-next-line no-unused-expressions + () => + getPathname({ + href: { + pathname: '/users/[userId]', + // @ts-expect-error -- Not allowed + params: {userId: 3} + }, + locale: 'en' + }); + }); - it('does not accept `params` on href', () => { - // eslint-disable-next-line no-unused-expressions - () => - getPathname({ - href: { - pathname: '/users/[userId]', - // @ts-expect-error -- Not allowed - params: {userId: 3} - }, - locale: 'en' - }); + it('requires a locale', () => { + // Some background: This function can be used either in the `react-server` + // or the `react-client` environment. Since the function signature doesn't + // impose a limit on where it can be called (e.g. during rendering), we + // can't determine the current locale in the `react-client` environment. + // While we could theoretically retrieve the current locale in the + // `react-server` environment we need a shared function signature that + // works in either environment. + + // @ts-expect-error -- Missing locale + // eslint-disable-next-line no-unused-expressions + () => getPathname({href: '/about'}); + }); }); - it('requires a locale', () => { - // Some background: This function can be used either in the `react-server` - // or the `react-client` environment. Since the function signature doesn't - // impose a limit on where it can be called (e.g. during rendering), we - // can't determine the current locale in the `react-client` environment. - // While we could theoretically retrieve the current locale in the - // `react-server` environment we need a shared function signature that - // works in either environment. - - // @ts-expect-error -- Missing locale - // eslint-disable-next-line no-unused-expressions - () => getPathname({href: '/about'}); + describe.each([ + ['redirect', redirect, nextRedirect], + ['permanentRedirect', permanentRedirect, nextPermanentRedirect] + ])('%s', (_, redirectFn, nextRedirectFn) => { + it('can redirect for the default locale', () => { + runInRender(() => redirectFn('/')); + expect(nextRedirectFn).toHaveBeenLastCalledWith('/en'); + }); + + it('forwards a redirect type', () => { + runInRender(() => redirectFn('/', RedirectType.push)); + expect(nextRedirectFn).toHaveBeenLastCalledWith( + '/en', + RedirectType.push + ); + }); + + // There's nothing strictly against this, but there was no need for this so + // far. The API design is a bit tricky since Next.js uses the second argument + // for a plain `type` string. Should we support an object here? Also consider + // API symmetry with `router.push`. + it('can not redirect for a different locale', () => { + // @ts-expect-error + // eslint-disable-next-line no-unused-expressions + () => redirectFn('/about', {locale: 'de'}); + }); + + it('handles relative pathnames', () => { + runInRender(() => redirectFn('about')); + expect(nextRedirectFn).toHaveBeenLastCalledWith('about'); + }); + + it('handles query params', () => { + runInRender(() => + redirectFn({pathname: '/about', query: {foo: 'bar'}}) + ); + expect(nextRedirectFn).toHaveBeenLastCalledWith('/en/about?foo=bar'); + }); }); }); - describe.each([ - ['redirect', redirect, nextRedirect], - ['permanentRedirect', permanentRedirect, nextPermanentRedirect] - ])('%s', (_, redirectFn, nextRedirectFn) => { - it('can redirect for the default locale', () => { - runInRender(() => redirectFn('/')); - expect(nextRedirectFn).toHaveBeenLastCalledWith('/en'); + describe("localePrefix: 'always', no `locales`", () => { + const {Link, getPathname, permanentRedirect, redirect} = createNavigation({ + localePrefix: 'always' }); - it('forwards a redirect type', () => { - runInRender(() => redirectFn('/', RedirectType.push)); - expect(nextRedirectFn).toHaveBeenLastCalledWith('/en', RedirectType.push); - }); + describe('createNavigation', () => { + it('can create navigation APIs with no arguments at all', () => { + createNavigation(); + }); - // There's nothing strictly against this, but there was no need for this so - // far. The API design is a bit tricky since Next.js uses the second argument - // for a plain `type` string. Should we support an object here? Also consider - // API symmetry with `router.push`. - it('can not redirect for a different locale', () => { - // @ts-expect-error - // eslint-disable-next-line no-unused-expressions - () => redirectFn('/about', {locale: 'de'}); + it('can not be used with `pathnames`', () => { + // @ts-expect-error -- Missing locales + createNavigation({pathnames}); + }); }); - it('handles relative pathnames', () => { - runInRender(() => redirectFn('about')); - expect(nextRedirectFn).toHaveBeenLastCalledWith('about'); - }); + describe('Link', () => { + it('renders a prefix for the current locale', () => { + const markup = renderToString(About); + expect(markup).toContain('href="/en/about"'); + }); - it('handles query params', () => { - runInRender(() => redirectFn({pathname: '/about', query: {foo: 'bar'}})); - expect(nextRedirectFn).toHaveBeenLastCalledWith('/en/about?foo=bar'); + it('renders a prefix for a different locale', () => { + const markup = renderToString( + + About + + ); + expect(markup).toContain('href="/zh/about"'); + }); }); - }); -}); -describe("localePrefix: 'always', no `locales`", () => { - const {Link, getPathname, permanentRedirect, redirect} = createNavigation({ - localePrefix: 'always' - }); + describe('getPathname', () => { + it('adds a prefix for the default locale', () => { + expect(getPathname({href: '/about', locale: 'en'})).toBe('/en/about'); + }); - describe('createNavigation', () => { - it('can create navigation APIs with no arguments at all', () => { - createNavigation(); + it('adds a prefix for a secondary locale', () => { + expect(getPathname({href: '/about', locale: 'zh'})).toBe('/zh/about'); + }); }); - it('can not be used with `pathnames`', () => { - // @ts-expect-error -- Missing locales - createNavigation({pathnames}); + describe.each([ + ['redirect', redirect, nextRedirect], + ['permanentRedirect', permanentRedirect, nextPermanentRedirect] + ])('%s', (_, redirectFn, nextRedirectFn) => { + it('can redirect for the current locale', () => { + runInRender(() => redirectFn('/')); + expect(nextRedirectFn).toHaveBeenLastCalledWith('/en'); + }); }); }); - describe('Link', () => { - it('renders a prefix for the current locale', () => { - const markup = renderToString(About); - expect(markup).toContain('href="/en/about"'); + describe("localePrefix: 'always', with `pathnames`", () => { + const {Link, getPathname, permanentRedirect, redirect} = createNavigation({ + locales, + defaultLocale, + localePrefix: 'always', + pathnames }); - it('renders a prefix for a different locale', () => { - const markup = renderToString( - - About - - ); - expect(markup).toContain('href="/zh/about"'); - }); - }); + describe('createNavigation', () => { + it('requires `locales` for `pathnames`', () => { + // @ts-expect-error -- Missing locales + createNavigation({ + pathnames: {'/': '/'} + }); + }); - describe('getPathname', () => { - it('adds a prefix for the default locale', () => { - expect(getPathname({href: '/about', locale: 'en'})).toBe('/en/about'); + it('can be called with a `routing` object', () => { + createNavigation( + defineRouting({ + locales: ['en', 'de'], + defaultLocale: 'en' + }) + ); + createNavigation( + defineRouting({ + locales: ['en', 'de'], + defaultLocale: 'en', + pathnames: { + home: '/', + about: { + en: '/about', + de: '/ueber-uns' + } + } + }) + ); + }); }); - it('adds a prefix for a secondary locale', () => { - expect(getPathname({href: '/about', locale: 'zh'})).toBe('/zh/about'); - }); - }); + describe('Link', () => { + it('renders a prefix when currently on the default locale', () => { + const markup = renderToString(About); + expect(markup).toContain('href="/en/about"'); + }); - describe.each([ - ['redirect', redirect, nextRedirect], - ['permanentRedirect', permanentRedirect, nextPermanentRedirect] - ])('%s', (_, redirectFn, nextRedirectFn) => { - it('can redirect for the current locale', () => { - runInRender(() => redirectFn('/')); - expect(nextRedirectFn).toHaveBeenLastCalledWith('/en'); - }); - }); -}); + it('renders a prefix when currently on a secondary locale', () => { + mockCurrentLocale('de'); + const markup = renderToString(About); + expect(markup).toContain('href="/de/ueber-uns"'); + }); -describe("localePrefix: 'always', with `pathnames`", () => { - const {Link, getPathname, permanentRedirect, redirect} = createNavigation({ - locales, - defaultLocale, - localePrefix: 'always', - pathnames - }); + it('renders a prefix for a different locale', () => { + const markup = renderToString( + + Über uns + + ); + expect(markup).toContain('href="/de/ueber-uns"'); + }); - describe('createNavigation', () => { - it('requires `locales` for `pathnames`', () => { - // @ts-expect-error -- Missing locales - createNavigation({ - pathnames: {'/': '/'} + it('renders an object href', () => { + render( + About + ); + expect( + screen.getByRole('link', {name: 'About'}).getAttribute('href') + ).toBe('/en/about?foo=bar'); }); - }); - it('can be called with a `routing` object', () => { - createNavigation( - defineRouting({ - locales: ['en', 'de'], - defaultLocale: 'en' - }) - ); - createNavigation( - defineRouting({ - locales: ['en', 'de'], - defaultLocale: 'en', - pathnames: { - home: '/', - about: { - en: '/about', - de: '/ueber-uns' - } - } - }) - ); - }); - }); + it('handles params', () => { + render( + + About + + ); + expect( + screen.getByRole('link', {name: 'About'}).getAttribute('href') + ).toBe('/de/neuigkeiten/launch-party-3'); + }); - describe('Link', () => { - it('renders a prefix when currently on the default locale', () => { - const markup = renderToString(About); - expect(markup).toContain('href="/en/about"'); - }); + it('handles relative links', () => { + // @ts-expect-error -- Validation is still on + const markup = renderToString(Test); + expect(markup).toContain('href="test"'); + }); - it('renders a prefix when currently on a secondary locale', () => { - mockCurrentLocale('de'); - const markup = renderToString(About); - expect(markup).toContain('href="/de/ueber-uns"'); - }); + it('handles external links correctly', () => { + const markup = renderToString( + // @ts-expect-error -- Validation is still on + Test + ); + expect(markup).toContain('href="https://example.com/test"'); + }); - it('renders a prefix for a different locale', () => { - const markup = renderToString( - - Über uns - - ); - expect(markup).toContain('href="/de/ueber-uns"'); + it('restricts invalid usage', () => { + // @ts-expect-error -- Unknown locale + ; + // @ts-expect-error -- Unknown pathname + ; + // @ts-expect-error -- Missing params + ; + }); }); - it('renders an object href', () => { - render( - About - ); - expect( - screen.getByRole('link', {name: 'About'}).getAttribute('href') - ).toBe('/en/about?foo=bar'); - }); + describe('getPathname', () => { + it('can be called with a known pathname', () => { + expect(getPathname({href: '/about', locale: 'en'})).toBe('/en/about'); + expect( + getPathname({ + href: {pathname: '/about', query: {foo: 'bar'}}, + locale: 'en' + }) + ).toBe('/en/about?foo=bar'); + }); - it('handles params', () => { - render( - { + expect( + getPathname({ + locale: 'en', + href: { + pathname: '/news/[articleSlug]-[articleId]', + params: { + articleId: 3, + articleSlug: 'launch-party' + }, + query: {foo: 'bar'} } - }} - locale="de" - > - About - - ); - expect( - screen.getByRole('link', {name: 'About'}).getAttribute('href') - ).toBe('/de/neuigkeiten/launch-party-3'); - }); + }) + ).toBe('/en/news/launch-party-3?foo=bar'); + }); - it('handles relative links', () => { - // @ts-expect-error -- Validation is still on - const markup = renderToString(Test); - expect(markup).toContain('href="test"'); - }); + it('can not be called with an arbitrary pathname', () => { + // @ts-expect-error -- Unknown pathname + expect(getPathname({locale: 'en', href: '/unknown'})) + // Works regardless + .toBe('/en/unknown'); + }); - it('handles external links correctly', () => { - const markup = renderToString( + it('handles relative pathnames', () => { // @ts-expect-error -- Validation is still on - Test - ); - expect(markup).toContain('href="https://example.com/test"'); + expect(getPathname({locale: 'en', href: 'about'})).toBe('about'); + }); }); - it('restricts invalid usage', () => { - // @ts-expect-error -- Unknown locale - ; - // @ts-expect-error -- Unknown pathname - ; - // @ts-expect-error -- Missing params - ; - }); - }); - - describe('getPathname', () => { - it('can be called with a known pathname', () => { - expect(getPathname({href: '/about', locale: 'en'})).toBe('/en/about'); - expect( - getPathname({ - href: {pathname: '/about', query: {foo: 'bar'}}, - locale: 'en' - }) - ).toBe('/en/about?foo=bar'); - }); + describe.each([ + ['redirect', redirect, nextRedirect], + ['permanentRedirect', permanentRedirect, nextPermanentRedirect] + ])('%s', (_, redirectFn, nextRedirectFn) => { + it('can redirect for the default locale', () => { + runInRender(() => redirectFn('/')); + expect(nextRedirectFn).toHaveBeenLastCalledWith('/en'); + }); - it('can resolve a pathname with params', () => { - expect( - getPathname({ - locale: 'en', - href: { + it('can redirect with params and query params', () => { + runInRender(() => + redirectFn({ pathname: '/news/[articleSlug]-[articleId]', params: { articleId: 3, articleSlug: 'launch-party' }, query: {foo: 'bar'} - } - }) - ).toBe('/en/news/launch-party-3?foo=bar'); - }); + }) + ); + expect(nextRedirectFn).toHaveBeenLastCalledWith( + '/en/news/launch-party-3?foo=bar' + ); + }); - it('can not be called with an arbitrary pathname', () => { - // @ts-expect-error -- Unknown pathname - expect(getPathname({locale: 'en', href: '/unknown'})) + it('can not be called with an arbitrary pathname', () => { + // @ts-expect-error -- Unknown pathname + runInRender(() => redirectFn('/unknown')); // Works regardless - .toBe('/en/unknown'); - }); - - it('handles relative pathnames', () => { - // @ts-expect-error -- Validation is still on - expect(getPathname({locale: 'en', href: 'about'})).toBe('about'); - }); - }); + expect(nextRedirectFn).toHaveBeenLastCalledWith('/en/unknown'); + }); - describe.each([ - ['redirect', redirect, nextRedirect], - ['permanentRedirect', permanentRedirect, nextPermanentRedirect] - ])('%s', (_, redirectFn, nextRedirectFn) => { - it('can redirect for the default locale', () => { - runInRender(() => redirectFn('/')); - expect(nextRedirectFn).toHaveBeenLastCalledWith('/en'); - }); + it('forwards a redirect type', () => { + runInRender(() => redirectFn('/', RedirectType.push)); + expect(nextRedirectFn).toHaveBeenLastCalledWith( + '/en', + RedirectType.push + ); + }); - it('can redirect with params and query params', () => { - runInRender(() => - redirectFn({ - pathname: '/news/[articleSlug]-[articleId]', - params: { - articleId: 3, - articleSlug: 'launch-party' - }, - query: {foo: 'bar'} - }) - ); - expect(nextRedirectFn).toHaveBeenLastCalledWith( - '/en/news/launch-party-3?foo=bar' - ); + it('can handle relative pathnames', () => { + // @ts-expect-error -- Validation is still on + runInRender(() => redirectFn('about')); + expect(nextRedirectFn).toHaveBeenLastCalledWith('about'); + }); }); + }); - it('can not be called with an arbitrary pathname', () => { - // @ts-expect-error -- Unknown pathname - runInRender(() => redirectFn('/unknown')); - // Works regardless - expect(nextRedirectFn).toHaveBeenLastCalledWith('/en/unknown'); + describe("localePrefix: 'as-needed'", () => { + const {Link, getPathname, permanentRedirect, redirect} = createNavigation({ + locales, + defaultLocale, + localePrefix: 'as-needed' }); - it('forwards a redirect type', () => { - runInRender(() => redirectFn('/', RedirectType.push)); - expect(nextRedirectFn).toHaveBeenLastCalledWith('/en', RedirectType.push); + describe('createNavigation', () => { + it('errors when no `defaultLocale` is set', () => { + expect( + () => void createNavigation({localePrefix: 'as-needed'}) + ).toThrowError( + "`localePrefix: 'as-needed' requires a `defaultLocale`." + ); + }); }); - it('can handle relative pathnames', () => { - // @ts-expect-error -- Validation is still on - runInRender(() => redirectFn('about')); - expect(nextRedirectFn).toHaveBeenLastCalledWith('about'); - }); - }); -}); + describe('Link', () => { + it('does not render a prefix when currently on the default locale', () => { + const markup = renderToString(About); + expect(markup).toContain('href="/about"'); + }); -describe("localePrefix: 'as-needed'", () => { - const {Link, getPathname, permanentRedirect, redirect} = createNavigation({ - locales, - defaultLocale, - localePrefix: 'as-needed' - }); + it('renders a prefix when currently on a secondary locale', () => { + mockCurrentLocale('de'); + const markup = renderToString(About); + expect(markup).toContain('href="/de/about"'); + }); - describe('createNavigation', () => { - it('errors when no `defaultLocale` is set', () => { - expect( - () => void createNavigation({localePrefix: 'as-needed'}) - ).toThrowError("`localePrefix: 'as-needed' requires a `defaultLocale`."); - }); - }); + it('renders a prefix for a different locale', () => { + const markup = renderToString( + + Über uns + + ); + expect(markup).toContain('href="/de/about"'); + }); - describe('Link', () => { - it('does not render a prefix when currently on the default locale', () => { - const markup = renderToString(About); - expect(markup).toContain('href="/about"'); - }); + it('renders a prefix when currently on a secondary locale and linking to the default locale', () => { + mockCurrentLocale('de'); + const markup = renderToString( + + About + + ); + expect(markup).toContain('href="/en/about"'); + }); - it('renders a prefix when currently on a secondary locale', () => { - mockCurrentLocale('de'); - const markup = renderToString(About); - 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('renders a prefix for a different locale', () => { - const markup = renderToString( - - Über uns - - ); - expect(markup).toContain('href="/de/about"'); - }); + it('handles params', () => { + render( + + About + + ); + expect( + screen.getByRole('link', {name: 'About'}).getAttribute('href') + ).toBe('/de/news/launch-party-3'); + }); - it('renders a prefix when currently on a secondary locale and linking to the default locale', () => { - mockCurrentLocale('de'); - const markup = renderToString( - - About - - ); - expect(markup).toContain('href="/en/about"'); - }); + it('handles relative links correctly on the initial render', () => { + const markup = renderToString(Test); + expect(markup).toContain('href="test"'); + }); - it('renders an object href', () => { - render( - About - ); - expect( - screen.getByRole('link', {name: 'About'}).getAttribute('href') - ).toBe('/about?foo=bar'); + it('does not accept `params`', () => { + ; + }); }); - it('handles params', () => { - render( - - About - - ); - expect( - screen.getByRole('link', {name: 'About'}).getAttribute('href') - ).toBe('/de/news/launch-party-3'); - }); + describe('getPathname', () => { + it('does not add a prefix for the default locale', () => { + expect(getPathname({locale: 'en', href: '/about'})).toBe('/about'); + }); - it('handles relative links correctly on the initial render', () => { - const markup = renderToString(Test); - expect(markup).toContain('href="test"'); - }); + it('adds a prefix for a secondary locale', () => { + expect(getPathname({locale: 'de', href: '/about'})).toBe('/de/about'); + }); - it('does not accept `params`', () => { - ; + it('requires a locale', () => { + // @ts-expect-error -- Missing locale + // eslint-disable-next-line no-unused-expressions + () => getPathname({href: '/about'}); + }); }); - }); - describe('getPathname', () => { - it('does not add a prefix for the default locale', () => { - expect(getPathname({locale: 'en', href: '/about'})).toBe('/about'); - }); + describe.each([ + ['redirect', redirect, nextRedirect], + ['permanentRedirect', permanentRedirect, nextPermanentRedirect] + ])('%s', (_, redirectFn, nextRedirectFn) => { + it('does not add a prefix when redirecting within the default locale', () => { + runInRender(() => redirectFn('/')); + expect(nextRedirectFn).toHaveBeenLastCalledWith('/'); + }); - it('adds a prefix for a secondary locale', () => { - expect(getPathname({locale: 'de', href: '/about'})).toBe('/de/about'); - }); + it('adds a prefix when currently on a secondary locale', () => { + mockCurrentLocale('de'); + runInRender(() => redirectFn('/')); + expect(nextRedirectFn).toHaveBeenLastCalledWith('/de'); + }); - it('requires a locale', () => { - // @ts-expect-error -- Missing locale - // eslint-disable-next-line no-unused-expressions - () => getPathname({href: '/about'}); + it('forwards a redirect type', () => { + runInRender(() => redirectFn('/', RedirectType.push)); + expect(nextRedirectFn).toHaveBeenLastCalledWith('/', RedirectType.push); + }); }); }); - describe.each([ - ['redirect', redirect, nextRedirect], - ['permanentRedirect', permanentRedirect, nextPermanentRedirect] - ])('%s', (_, redirectFn, nextRedirectFn) => { - it('does not add a prefix when redirecting within the default locale', () => { - runInRender(() => redirectFn('/')); - expect(nextRedirectFn).toHaveBeenLastCalledWith('/'); - }); + // describe("localePrefix: 'always', with `prefixes`", () => {}) + // describe("localePrefix: 'as-needed', no `locales`", () => {}) + // describe("localePrefix: 'as-needed', with `domains`", () => {}) + // describe("localePrefix: 'never', with `domains`", () => {}) + // describe("localePrefix: 'always', with `domains`", () => {}) - it('adds a prefix when currently on a secondary locale', () => { - mockCurrentLocale('de'); - runInRender(() => redirectFn('/')); - expect(nextRedirectFn).toHaveBeenLastCalledWith('/de'); + describe("localePrefix: 'never'", () => { + const {Link, getPathname, permanentRedirect, redirect} = createNavigation({ + locales, + defaultLocale, + localePrefix: 'never' }); - it('forwards a redirect type', () => { - runInRender(() => redirectFn('/', RedirectType.push)); - expect(nextRedirectFn).toHaveBeenLastCalledWith('/', RedirectType.push); - }); - }); -}); - -// describe("localePrefix: 'always', with `prefixes`", () => {}) -// describe("localePrefix: 'as-needed', no `locales`", () => {}) -// describe("localePrefix: 'as-needed', with `domains`", () => {}) -// describe("localePrefix: 'never', with `domains`", () => {}) -// describe("localePrefix: 'always', with `domains`", () => {}) - -describe("localePrefix: 'never'", () => { - const {Link, getPathname, permanentRedirect, redirect} = createNavigation({ - locales, - defaultLocale, - localePrefix: 'never' - }); - - describe('Link', () => { - it('renders no prefix when currently on the default locale', () => { - const markup = renderToString(About); - expect(markup).toContain('href="/about"'); - }); + describe('Link', () => { + it('renders no prefix when currently on the default locale', () => { + const markup = renderToString(About); + expect(markup).toContain('href="/about"'); + }); - it('renders no prefix when currently on a secondary locale', () => { - mockCurrentLocale('de'); - const markup = renderToString(About); - expect(markup).toContain('href="/about"'); - }); + it('renders no prefix when currently on a secondary locale', () => { + mockCurrentLocale('de'); + const markup = renderToString(About); + expect(markup).toContain('href="/about"'); + }); - it('renders a prefix when linking to a secondary locale', () => { - const markup = renderToString( - - Über uns - - ); - expect(markup).toContain('href="/de/about"'); - expect(markup).toContain('hrefLang="de"'); - }); + it('renders a prefix when linking to a secondary locale', () => { + const markup = renderToString( + + Über uns + + ); + expect(markup).toContain('href="/de/about"'); + expect(markup).toContain('hrefLang="de"'); + }); - it('renders a prefix when currently on a secondary locale and linking to the default locale', () => { - mockCurrentLocale('de'); - const markup = renderToString( - - About - - ); - expect(markup).toContain('href="/en/about"'); + it('renders a prefix when currently on a secondary locale and linking to the default locale', () => { + mockCurrentLocale('de'); + const markup = renderToString( + + About + + ); + expect(markup).toContain('href="/en/about"'); + }); }); - }); - describe('getPathname', () => { - it('does not add a prefix for the default locale', () => { - expect(getPathname({locale: 'en', href: '/unknown'})).toBe('/unknown'); - }); + describe('getPathname', () => { + it('does not add a prefix for the default locale', () => { + expect(getPathname({locale: 'en', href: '/unknown'})).toBe('/unknown'); + }); - it('does not add a prefix for a secondary locale', () => { - expect(getPathname({locale: 'de', href: '/about'})).toBe('/about'); - }); + it('does not add a prefix for a secondary locale', () => { + expect(getPathname({locale: 'de', href: '/about'})).toBe('/about'); + }); - it('requires a locale', () => { - // @ts-expect-error -- Missing locale - // eslint-disable-next-line no-unused-expressions - () => getPathname({href: '/about'}); + it('requires a locale', () => { + // @ts-expect-error -- Missing locale + // eslint-disable-next-line no-unused-expressions + () => getPathname({href: '/about'}); + }); }); - }); - describe.each([ - ['redirect', redirect, nextRedirect], - ['permanentRedirect', permanentRedirect, nextPermanentRedirect] - ])('%s', (_, redirectFn, nextRedirectFn) => { - it('can redirect for the default locale', () => { - runInRender(() => redirectFn('/')); - expect(nextRedirectFn).toHaveBeenLastCalledWith('/'); - }); + describe.each([ + ['redirect', redirect, nextRedirect], + ['permanentRedirect', permanentRedirect, nextPermanentRedirect] + ])('%s', (_, redirectFn, nextRedirectFn) => { + it('can redirect for the default locale', () => { + runInRender(() => redirectFn('/')); + expect(nextRedirectFn).toHaveBeenLastCalledWith('/'); + }); - it('forwards a redirect type', () => { - runInRender(() => redirectFn('/', RedirectType.push)); - expect(nextRedirectFn).toHaveBeenLastCalledWith('/', RedirectType.push); + it('forwards a redirect type', () => { + runInRender(() => redirectFn('/', RedirectType.push)); + expect(nextRedirectFn).toHaveBeenLastCalledWith('/', RedirectType.push); + }); }); }); }); diff --git a/packages/next-intl/src/navigation/react-client/createNavigation.tsx b/packages/next-intl/src/navigation/react-client/createNavigation.tsx index 4fc2d2b77..50417eb4a 100644 --- a/packages/next-intl/src/navigation/react-client/createNavigation.tsx +++ b/packages/next-intl/src/navigation/react-client/createNavigation.tsx @@ -1 +1,38 @@ -export default function createNavigation() {} +import useLocale from '../../react-client/useLocale'; +import { + RoutingConfigLocalizedNavigation, + RoutingConfigSharedNavigation +} from '../../routing/config'; +import {Locales, Pathnames} from '../../routing/types'; +import createSharedNavigationFns from '../shared/createSharedNavigationFns'; + +export default function createNavigation< + const AppLocales extends Locales, + const AppPathnames extends Pathnames = never +>( + routing?: [AppPathnames] extends [never] + ? RoutingConfigSharedNavigation | undefined + : RoutingConfigLocalizedNavigation +) { + type Locale = AppLocales extends never ? string : AppLocales[number]; + + function getLocale() { + let locale; + try { + // eslint-disable-next-line react-hooks/rules-of-hooks -- Reading from context here is fine, since `redirect` must be called during render + locale = useLocale(); + } catch (e) { + if (process.env.NODE_ENV !== 'production') { + throw new Error( + '`redirect()` and `permanentRedirect()` can only be called during render. To redirect in an event handler or similar, you can use `useRouter()` instead.' + ); + } + throw e; + } + return locale as Locale; + } + + const fns = createSharedNavigationFns(getLocale, routing); + + return fns; +} diff --git a/packages/next-intl/src/navigation/react-server/createNavigation.test.tsx b/packages/next-intl/src/navigation/react-server/createNavigation.test.tsx new file mode 100644 index 000000000..dc491b08b --- /dev/null +++ b/packages/next-intl/src/navigation/react-server/createNavigation.test.tsx @@ -0,0 +1,26 @@ +import {describe, expect, it, vi} from 'vitest'; +import createNavigation from './createNavigation'; + +vi.mock('react'); + +const {usePathname, useRouter} = createNavigation(); + +describe('usePathname', () => { + it('should throw an error', () => { + expect(() => { + usePathname(); + }).toThrowError( + '`usePathname` is not supported in Server Components. You can use this hook if you convert the calling component to a Client Component.' + ); + }); +}); + +describe('useRouter', () => { + it('should throw an error', () => { + expect(() => { + useRouter(); + }).toThrowError( + '`useRouter` is not supported in Server Components. You can use this hook if you convert the calling component to a Client Component.' + ); + }); +}); diff --git a/packages/next-intl/src/navigation/react-server/createNavigation.tsx b/packages/next-intl/src/navigation/react-server/createNavigation.tsx index 8c497e6ae..6f29b60df 100644 --- a/packages/next-intl/src/navigation/react-server/createNavigation.tsx +++ b/packages/next-intl/src/navigation/react-server/createNavigation.tsx @@ -1,29 +1,10 @@ import { - permanentRedirect as nextPermanentRedirect, - redirect as nextRedirect -} from 'next/navigation'; -import React, {ComponentProps} from 'react'; -import { - receiveRoutingConfig, - ResolvedRoutingConfig, RoutingConfigLocalizedNavigation, RoutingConfigSharedNavigation } from '../../routing/config'; import {Locales, Pathnames} from '../../routing/types'; import {getRequestLocale} from '../../server/react-server/RequestLocale'; -import {ParametersExceptFirst} from '../../shared/types'; -import {isLocalizableHref} from '../../shared/utils'; -import BaseLink from '../shared/BaseLink'; -import { - HrefOrHrefWithParams, - HrefOrUrlObjectWithParams, - QueryParams, - applyPathnamePrefix, - compileLocalizedPathname, - normalizeNameOrNameWithParams, - serializeSearchParams, - validateReceivedConfig -} from '../shared/utils'; +import createSharedNavigationFns from '../shared/createSharedNavigationFns'; export default function createNavigation< const AppLocales extends Locales, @@ -35,145 +16,11 @@ export default function createNavigation< ) { type Locale = AppLocales extends never ? string : AppLocales[number]; - const config = receiveRoutingConfig( - routing || {} - ) as typeof routing extends undefined - ? Pick, 'localePrefix'> - : [AppPathnames] extends [never] - ? ResolvedRoutingConfig - : ResolvedRoutingConfig; - if (process.env.NODE_ENV !== 'production') { - validateReceivedConfig(config); - } - - const pathnames = (config as any).pathnames as [AppPathnames] extends [never] - ? undefined - : AppPathnames; - - function getCurrentLocale() { + function getLocale() { return getRequestLocale() as Locale; } - type LinkProps = Omit< - ComponentProps, - 'href' | 'localePrefix' - > & { - href: [AppPathnames] extends [never] - ? ComponentProps['href'] - : HrefOrUrlObjectWithParams; - locale?: Locale; - }; - function Link({ - href, - locale, - ...rest - }: LinkProps) { - let pathname, params; - if (typeof href === 'object') { - pathname = href.pathname; - // @ts-expect-error -- This is ok - params = href.params; - } else { - pathname = href; - } - - const curLocale = getCurrentLocale(); - - // @ts-expect-error -- This is ok - const finalPathname = isLocalizableHref(href) - ? getPathname( - { - locale: locale || curLocale, - // @ts-expect-error -- This is ok - href: pathnames == null ? pathname : {pathname, params} - }, - locale != null - ) - : pathname; - - return ( - - ); - } - - // New: getPathname is available for shared pathnames - function getPathname( - { - href, - locale - }: { - locale: Locale; - href: [AppPathnames] extends [never] - ? string | {pathname: string; query?: QueryParams} - : HrefOrHrefWithParams; - }, - /** @private */ - _forcePrefix?: boolean - // TODO: Should we somehow ensure this doesn't get emitted to the types? - ) { - let pathname: string; - if (pathnames == null) { - if (typeof href === 'object') { - pathname = href.pathname as string; - if (href.query) { - pathname += serializeSearchParams(href.query); - } - } else { - pathname = href as string; - } - } else { - pathname = compileLocalizedPathname({ - locale, - // @ts-expect-error -- This is ok - ...normalizeNameOrNameWithParams(href), - // @ts-expect-error -- This is ok - pathnames: config.pathnames - }); - } - - // TODO: There might be only one shot here, for as-needed - // would be reading `host`, but that breaks SSG. If you want - // to get the first shot right, pass a `domain` here (then - // the user opts into dynamic rendering) - return applyPathnamePrefix({ - pathname, - locale, - routing: config, - force: _forcePrefix - }); - } - - function baseRedirect( - fn: typeof nextRedirect | typeof nextPermanentRedirect, - href: Parameters[0]['href'], - ...args: ParametersExceptFirst - ) { - const locale = getCurrentLocale(); - const isChangingLocale = typeof href === 'object' && 'locale' in href; - return fn(getPathname({href, locale}, isChangingLocale), ...args); - } - - function redirect( - href: Parameters[0]['href'], - ...args: ParametersExceptFirst - ) { - return baseRedirect(nextRedirect, href, ...args); - } - - function permanentRedirect( - href: Parameters[0]['href'], - ...args: ParametersExceptFirst - ) { - return baseRedirect(nextPermanentRedirect, href, ...args); - } + const fns = createSharedNavigationFns(getLocale, routing); function notSupported(hookName: string) { return () => { @@ -184,10 +31,7 @@ export default function createNavigation< } return { - Link, - redirect, - permanentRedirect, - getPathname, + ...fns, usePathname: notSupported('usePathname'), useRouter: notSupported('useRouter') }; diff --git a/packages/next-intl/src/navigation/shared/createSharedNavigationFns.tsx b/packages/next-intl/src/navigation/shared/createSharedNavigationFns.tsx new file mode 100644 index 000000000..f8eac6ded --- /dev/null +++ b/packages/next-intl/src/navigation/shared/createSharedNavigationFns.tsx @@ -0,0 +1,180 @@ +import { + permanentRedirect as nextPermanentRedirect, + redirect as nextRedirect +} from 'next/navigation'; +import React, {ComponentProps} from 'react'; +import { + receiveRoutingConfig, + ResolvedRoutingConfig, + RoutingConfigLocalizedNavigation, + RoutingConfigSharedNavigation +} from '../../routing/config'; +import {Locales, Pathnames} from '../../routing/types'; +import {ParametersExceptFirst} from '../../shared/types'; +import {isLocalizableHref} from '../../shared/utils'; +import BaseLink from './BaseLink'; +import { + HrefOrHrefWithParams, + HrefOrUrlObjectWithParams, + QueryParams, + applyPathnamePrefix, + compileLocalizedPathname, + normalizeNameOrNameWithParams, + serializeSearchParams, + validateReceivedConfig +} from './utils'; + +export default function createSharedNavigationFns< + const AppLocales extends Locales, + const AppPathnames extends Pathnames = never +>( + getLocale: () => AppLocales extends never ? string : AppLocales[number], + routing?: [AppPathnames] extends [never] + ? RoutingConfigSharedNavigation | undefined + : RoutingConfigLocalizedNavigation +) { + type Locale = ReturnType; + + const config = receiveRoutingConfig( + routing || {} + ) as typeof routing extends undefined + ? Pick, 'localePrefix'> + : [AppPathnames] extends [never] + ? ResolvedRoutingConfig + : ResolvedRoutingConfig; + if (process.env.NODE_ENV !== 'production') { + validateReceivedConfig(config); + } + + const pathnames = (config as any).pathnames as [AppPathnames] extends [never] + ? undefined + : AppPathnames; + + type LinkProps = Omit< + ComponentProps, + 'href' | 'localePrefix' + > & { + href: [AppPathnames] extends [never] + ? ComponentProps['href'] + : HrefOrUrlObjectWithParams; + locale?: Locale; + }; + function Link({ + href, + locale, + ...rest + }: LinkProps) { + let pathname, params; + if (typeof href === 'object') { + pathname = href.pathname; + // @ts-expect-error -- This is ok + params = href.params; + } else { + pathname = href; + } + + const curLocale = getLocale(); + + // @ts-expect-error -- This is ok + const finalPathname = isLocalizableHref(href) + ? getPathname( + { + locale: locale || curLocale, + // @ts-expect-error -- This is ok + href: pathnames == null ? pathname : {pathname, params} + }, + locale != null + ) + : pathname; + + return ( + + ); + } + + // New: getPathname is available for shared pathnames + function getPathname( + { + href, + locale + }: { + locale: Locale; + href: [AppPathnames] extends [never] + ? string | {pathname: string; query?: QueryParams} + : HrefOrHrefWithParams; + }, + /** @private */ + _forcePrefix?: boolean + // TODO: Should we somehow ensure this doesn't get emitted to the types? + ) { + let pathname: string; + if (pathnames == null) { + if (typeof href === 'object') { + pathname = href.pathname as string; + if (href.query) { + pathname += serializeSearchParams(href.query); + } + } else { + pathname = href as string; + } + } else { + pathname = compileLocalizedPathname({ + locale, + // @ts-expect-error -- This is ok + ...normalizeNameOrNameWithParams(href), + // @ts-expect-error -- This is ok + pathnames: config.pathnames + }); + } + + // TODO: There might be only one shot here, for as-needed + // would be reading `host`, but that breaks SSG. If you want + // to get the first shot right, pass a `domain` here (then + // the user opts into dynamic rendering) + return applyPathnamePrefix({ + pathname, + locale, + routing: config, + force: _forcePrefix + }); + } + + function baseRedirect( + fn: typeof nextRedirect | typeof nextPermanentRedirect, + href: Parameters[0]['href'], + ...args: ParametersExceptFirst + ) { + const locale = getLocale(); + const isChangingLocale = typeof href === 'object' && 'locale' in href; + return fn(getPathname({href, locale}, isChangingLocale), ...args); + } + + function redirect( + href: Parameters[0]['href'], + ...args: ParametersExceptFirst + ) { + return baseRedirect(nextRedirect, href, ...args); + } + + function permanentRedirect( + href: Parameters[0]['href'], + ...args: ParametersExceptFirst + ) { + return baseRedirect(nextPermanentRedirect, href, ...args); + } + + return { + Link, + redirect, + permanentRedirect, + getPathname + }; +} From c800f84bb8a9f72c3a6bf152b7b2fa6c9035a842 Mon Sep 17 00:00:00 2001 From: Jan Amann Date: Thu, 5 Sep 2024 14:20:19 +0200 Subject: [PATCH 12/62] usePathname --- .../src/navigation/createNavigation.test.tsx | 1 - .../react-client/createNavigation.test.tsx | 134 ++++++++++++++++++ .../react-client/createNavigation.tsx | 36 ++++- .../react-client/useBasePathname.tsx | 15 -- .../react-server/createNavigation.tsx | 3 +- .../shared/createSharedNavigationFns.tsx | 6 +- 6 files changed, 175 insertions(+), 20 deletions(-) create mode 100644 packages/next-intl/src/navigation/react-client/createNavigation.test.tsx diff --git a/packages/next-intl/src/navigation/createNavigation.test.tsx b/packages/next-intl/src/navigation/createNavigation.test.tsx index 58fa718a3..34695a2ba 100644 --- a/packages/next-intl/src/navigation/createNavigation.test.tsx +++ b/packages/next-intl/src/navigation/createNavigation.test.tsx @@ -19,7 +19,6 @@ vi.mock('next/navigation', async () => { return { ...actual, useParams: vi.fn(() => ({locale: 'en'})), - usePathname: vi.fn(() => '/'), redirect: vi.fn(), permanentRedirect: vi.fn() }; diff --git a/packages/next-intl/src/navigation/react-client/createNavigation.test.tsx b/packages/next-intl/src/navigation/react-client/createNavigation.test.tsx new file mode 100644 index 000000000..83852df51 --- /dev/null +++ b/packages/next-intl/src/navigation/react-client/createNavigation.test.tsx @@ -0,0 +1,134 @@ +import {render, screen} from '@testing-library/react'; +import { + useParams as useNextParams, + usePathname as useNextPathname +} from 'next/navigation'; +import React from 'react'; +import {beforeEach, describe, it, vi} from 'vitest'; +import createNavigation from './createNavigation'; + +vi.mock('next/navigation', async () => { + const actual = await vi.importActual('next/navigation'); + return { + ...actual, + useParams: vi.fn(() => ({locale: 'en'})), + usePathname: vi.fn(() => '/') + }; +}); + +function mockCurrentLocale(locale: string) { + vi.mocked(useNextParams<{locale: string}>).mockImplementation(() => ({ + locale + })); +} + +function mockCurrentPathname(string: string) { + vi.mocked(useNextPathname).mockImplementation(() => string); +} + +beforeEach(() => { + mockCurrentLocale('en'); + mockCurrentLocale('/en'); +}); + +const locales = ['en', 'de', 'ja'] as const; +const defaultLocale = 'en' as const; + +describe("localePrefix: 'always'", () => { + const {usePathname} = createNavigation({ + locales, + defaultLocale, + localePrefix: 'always' + }); + + function renderPathname() { + function Component() { + return usePathname(); + } + render(); + } + + describe('usePathname', () => { + it('returns the correct pathname for the default locale', () => { + mockCurrentLocale('en'); + mockCurrentPathname('/en/about'); + + renderPathname(); + screen.getByText('/about'); + }); + + it('returns the correct pathname for a secondary locale', () => { + mockCurrentLocale('de'); + mockCurrentPathname('/de/about'); + + renderPathname(); + screen.getByText('/about'); + }); + }); +}); + +describe("localePrefix: 'as-needed'", () => { + const {usePathname} = createNavigation({ + locales, + defaultLocale, + localePrefix: 'as-needed' + }); + + function renderPathname() { + function Component() { + return usePathname(); + } + render(); + } + + describe('usePathname', () => { + it('returns the correct pathname for the default locale', () => { + mockCurrentLocale('en'); + mockCurrentPathname('/about'); + + renderPathname(); + screen.getByText('/about'); + }); + + it('returns the correct pathname for a secondary locale', () => { + mockCurrentLocale('de'); + mockCurrentPathname('/de/about'); + + renderPathname(); + screen.getByText('/about'); + }); + }); +}); + +describe("localePrefix: 'never'", () => { + const {usePathname} = createNavigation({ + locales, + defaultLocale, + localePrefix: 'never' + }); + + function renderPathname() { + function Component() { + return usePathname(); + } + render(); + } + + describe('usePathname', () => { + it('returns the correct pathname for the default locale', () => { + mockCurrentLocale('en'); + mockCurrentPathname('/about'); + + renderPathname(); + screen.getByText('/about'); + }); + + it('returns the correct pathname for a secondary locale', () => { + mockCurrentLocale('de'); + mockCurrentPathname('/about'); + + renderPathname(); + screen.getByText('/about'); + }); + }); +}); diff --git a/packages/next-intl/src/navigation/react-client/createNavigation.tsx b/packages/next-intl/src/navigation/react-client/createNavigation.tsx index 50417eb4a..fdf238ce5 100644 --- a/packages/next-intl/src/navigation/react-client/createNavigation.tsx +++ b/packages/next-intl/src/navigation/react-client/createNavigation.tsx @@ -1,3 +1,4 @@ +import {useMemo} from 'react'; import useLocale from '../../react-client/useLocale'; import { RoutingConfigLocalizedNavigation, @@ -5,6 +6,8 @@ import { } from '../../routing/config'; import {Locales, Pathnames} from '../../routing/types'; import createSharedNavigationFns from '../shared/createSharedNavigationFns'; +import {getRoute} from '../shared/utils'; +import useBasePathname from './useBasePathname'; export default function createNavigation< const AppLocales extends Locales, @@ -32,7 +35,36 @@ export default function createNavigation< return locale as Locale; } - const fns = createSharedNavigationFns(getLocale, routing); + const {config, ...fns} = createSharedNavigationFns(getLocale, routing); - return fns; + /** + * Returns the pathname without a potential locale prefix. + * + * @see https://next-intl-docs.vercel.app/docs/routing/navigation#usepathname + */ + function usePathname(): string { + const pathname = useBasePathname(config.localePrefix); + const locale = getLocale(); + + // @ts-expect-error -- Mirror the behavior from Next.js, where `null` is returned when `usePathname` is used outside of Next, but the types indicate that a string is always returned. + return useMemo( + () => + pathname && + // @ts-expect-error -- This is fine + config.pathnames + ? getRoute( + locale, + pathname, + // @ts-expect-error -- This is fine + config.pathnames + ) + : pathname, + [locale, pathname] + ); + } + + // TODO + function useRouter() {} + + return {...fns, usePathname, useRouter}; } diff --git a/packages/next-intl/src/navigation/react-client/useBasePathname.tsx b/packages/next-intl/src/navigation/react-client/useBasePathname.tsx index 6d288e6ea..5f2184e92 100644 --- a/packages/next-intl/src/navigation/react-client/useBasePathname.tsx +++ b/packages/next-intl/src/navigation/react-client/useBasePathname.tsx @@ -1,5 +1,3 @@ -'use client'; - import {usePathname as useNextPathname} from 'next/navigation'; import {useMemo} from 'react'; import useLocale from '../../react-client/useLocale'; @@ -10,19 +8,6 @@ import { unprefixPathname } from '../../shared/utils'; -/** - * Returns the pathname without a potential locale prefix. - * - * @example - * ```tsx - * 'use client'; - * - * import {usePathname} from 'next-intl/client'; - * - * // When the user is on `/en`, this will be `/` - * const pathname = usePathname(); - * ``` - */ export default function useBasePathname( localePrefix: LocalePrefixConfigVerbose ) { diff --git a/packages/next-intl/src/navigation/react-server/createNavigation.tsx b/packages/next-intl/src/navigation/react-server/createNavigation.tsx index 6f29b60df..67e8afa56 100644 --- a/packages/next-intl/src/navigation/react-server/createNavigation.tsx +++ b/packages/next-intl/src/navigation/react-server/createNavigation.tsx @@ -20,7 +20,8 @@ export default function createNavigation< return getRequestLocale() as Locale; } - const fns = createSharedNavigationFns(getLocale, routing); + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const {config, ...fns} = createSharedNavigationFns(getLocale, routing); function notSupported(hookName: string) { return () => { diff --git a/packages/next-intl/src/navigation/shared/createSharedNavigationFns.tsx b/packages/next-intl/src/navigation/shared/createSharedNavigationFns.tsx index f8eac6ded..801badc36 100644 --- a/packages/next-intl/src/navigation/shared/createSharedNavigationFns.tsx +++ b/packages/next-intl/src/navigation/shared/createSharedNavigationFns.tsx @@ -24,6 +24,9 @@ import { validateReceivedConfig } from './utils'; +/** + * Shared implementations for `react-server` and `react-client` + */ export default function createSharedNavigationFns< const AppLocales extends Locales, const AppPathnames extends Pathnames = never @@ -175,6 +178,7 @@ export default function createSharedNavigationFns< Link, redirect, permanentRedirect, - getPathname + getPathname, + config }; } From 177528ccf5ceadc2f87277bf59be049b0dfec08f Mon Sep 17 00:00:00 2001 From: Jan Amann Date: Thu, 5 Sep 2024 14:26:42 +0200 Subject: [PATCH 13/62] test usePathname with custom prefix --- .../react-client/createNavigation.test.tsx | 38 ++++++++++++++++--- 1 file changed, 32 insertions(+), 6 deletions(-) diff --git a/packages/next-intl/src/navigation/react-client/createNavigation.test.tsx b/packages/next-intl/src/navigation/react-client/createNavigation.test.tsx index 83852df51..86f22c302 100644 --- a/packages/next-intl/src/navigation/react-client/createNavigation.test.tsx +++ b/packages/next-intl/src/navigation/react-client/createNavigation.test.tsx @@ -34,6 +34,15 @@ beforeEach(() => { const locales = ['en', 'de', 'ja'] as const; const defaultLocale = 'en' as const; +function getRenderPathname(usePathname: () => string) { + return () => { + function Component() { + return usePathname(); + } + render(); + }; +} + describe("localePrefix: 'always'", () => { const {usePathname} = createNavigation({ locales, @@ -41,12 +50,7 @@ describe("localePrefix: 'always'", () => { localePrefix: 'always' }); - function renderPathname() { - function Component() { - return usePathname(); - } - render(); - } + const renderPathname = getRenderPathname(usePathname); describe('usePathname', () => { it('returns the correct pathname for the default locale', () => { @@ -67,6 +71,28 @@ describe("localePrefix: 'always'", () => { }); }); +describe("localePrefix: 'always', custom `prefixes`", () => { + const {usePathname} = createNavigation({ + locales, + localePrefix: { + mode: 'always', + prefixes: { + en: '/uk' + } + } + }); + const renderPathname = getRenderPathname(usePathname); + + describe('usePathname', () => { + it('returns the correct pathname for a custom locale prefix', () => { + mockCurrentLocale('en'); + mockCurrentPathname('/uk/about'); + renderPathname(); + screen.getByText('/about'); + }); + }); +}); + describe("localePrefix: 'as-needed'", () => { const {usePathname} = createNavigation({ locales, From 43a0b1223a7891a4fa761363bc711612c82a5d24 Mon Sep 17 00:00:00 2001 From: Jan Amann Date: Thu, 5 Sep 2024 14:57:07 +0200 Subject: [PATCH 14/62] strictly typed return type for usePathname --- .../react-client/createNavigation.test.tsx | 45 ++++++++++++++++++- .../react-client/createNavigation.tsx | 4 +- 2 files changed, 46 insertions(+), 3 deletions(-) diff --git a/packages/next-intl/src/navigation/react-client/createNavigation.test.tsx b/packages/next-intl/src/navigation/react-client/createNavigation.test.tsx index 86f22c302..48a02b5ec 100644 --- a/packages/next-intl/src/navigation/react-client/createNavigation.test.tsx +++ b/packages/next-intl/src/navigation/react-client/createNavigation.test.tsx @@ -5,6 +5,7 @@ import { } from 'next/navigation'; import React from 'react'; import {beforeEach, describe, it, vi} from 'vitest'; +import {Pathnames} from '../../routing'; import createNavigation from './createNavigation'; vi.mock('next/navigation', async () => { @@ -34,7 +35,27 @@ beforeEach(() => { const locales = ['en', 'de', 'ja'] as const; const defaultLocale = 'en' as const; -function getRenderPathname(usePathname: () => string) { +const pathnames = { + '/': '/', + '/about': { + en: '/about', + de: '/ueber-uns', + ja: '/約' + }, + '/news/[articleSlug]-[articleId]': { + en: '/news/[articleSlug]-[articleId]', + de: '/neuigkeiten/[articleSlug]-[articleId]', + ja: '/ニュース/[articleSlug]-[articleId]' + }, + '/categories/[...parts]': { + en: '/categories/[...parts]', + de: '/kategorien/[...parts]', + ja: '/カテゴリ/[...parts]' + }, + '/catch-all/[[...parts]]': '/catch-all/[[...parts]]' +} satisfies Pathnames; + +function getRenderPathname(usePathname: () => Return) { return () => { function Component() { return usePathname(); @@ -49,7 +70,6 @@ describe("localePrefix: 'always'", () => { defaultLocale, localePrefix: 'always' }); - const renderPathname = getRenderPathname(usePathname); describe('usePathname', () => { @@ -71,6 +91,27 @@ describe("localePrefix: 'always'", () => { }); }); +describe("localePrefix: 'always', with `pathnames`", () => { + const {usePathname} = createNavigation({ + locales, + defaultLocale, + localePrefix: 'always', + pathnames + }); + + describe('usePathname', () => { + it('returns a typed pathname', () => { + type Return = ReturnType; + + '/about' satisfies Return; + '/categories/[...parts]' satisfies Return; + + // @ts-expect-error + '/unknown' satisfies Return; + }); + }); +}); + describe("localePrefix: 'always', custom `prefixes`", () => { const {usePathname} = createNavigation({ locales, diff --git a/packages/next-intl/src/navigation/react-client/createNavigation.tsx b/packages/next-intl/src/navigation/react-client/createNavigation.tsx index fdf238ce5..67ed49a7e 100644 --- a/packages/next-intl/src/navigation/react-client/createNavigation.tsx +++ b/packages/next-intl/src/navigation/react-client/createNavigation.tsx @@ -42,7 +42,9 @@ export default function createNavigation< * * @see https://next-intl-docs.vercel.app/docs/routing/navigation#usepathname */ - function usePathname(): string { + function usePathname(): [AppPathnames] extends [never] + ? string + : keyof AppPathnames { const pathname = useBasePathname(config.localePrefix); const locale = getLocale(); From 4e6f19da59bbbcaae76e9fcd462fc1a3a9514d6f Mon Sep 17 00:00:00 2001 From: Jan Amann Date: Thu, 5 Sep 2024 15:26:00 +0200 Subject: [PATCH 15/62] more tests for link --- .../react-client/createNavigation.test.tsx | 54 ++++++++++++++++--- .../react-client/createNavigation.tsx | 34 ++++++------ .../src/navigation/shared/BaseLink.tsx | 14 ++--- .../src/navigation/shared/LegacyBaseLink.tsx | 4 +- 4 files changed, 73 insertions(+), 33 deletions(-) diff --git a/packages/next-intl/src/navigation/react-client/createNavigation.test.tsx b/packages/next-intl/src/navigation/react-client/createNavigation.test.tsx index 48a02b5ec..4b911e335 100644 --- a/packages/next-intl/src/navigation/react-client/createNavigation.test.tsx +++ b/packages/next-intl/src/navigation/react-client/createNavigation.test.tsx @@ -1,10 +1,8 @@ import {render, screen} from '@testing-library/react'; -import { - useParams as useNextParams, - usePathname as useNextPathname -} from 'next/navigation'; +import {useParams, usePathname as useNextPathname} from 'next/navigation'; import React from 'react'; -import {beforeEach, describe, it, vi} from 'vitest'; +import {beforeEach, describe, expect, it, vi} from 'vitest'; +import {NextIntlClientProvider} from '../../react-client'; import {Pathnames} from '../../routing'; import createNavigation from './createNavigation'; @@ -18,7 +16,7 @@ vi.mock('next/navigation', async () => { }); function mockCurrentLocale(locale: string) { - vi.mocked(useNextParams<{locale: string}>).mockImplementation(() => ({ + vi.mocked(useParams<{locale: string}>).mockImplementation(() => ({ locale })); } @@ -65,13 +63,55 @@ function getRenderPathname(usePathname: () => Return) { } describe("localePrefix: 'always'", () => { - const {usePathname} = createNavigation({ + const {Link, usePathname} = createNavigation({ locales, defaultLocale, localePrefix: 'always' }); const renderPathname = getRenderPathname(usePathname); + describe('Link', () => { + describe('usage outside of Next.js', () => { + beforeEach(() => { + vi.mocked(useParams).mockImplementation((() => null) as any); + }); + + it('works with a provider', () => { + render( + + Test + + ); + expect( + screen.getByRole('link', {name: 'Test'}).getAttribute('href') + ).toBe('/en/test'); + }); + + it('throws without a provider', () => { + expect(() => render(Test)).toThrow( + 'No intl context found. Have you configured the provider?' + ); + }); + }); + + it('can receive a ref', () => { + let ref; + + render( + { + ref = node; + }} + href="/test" + > + Test + + ); + + expect(ref).toBeDefined(); + }); + }); + describe('usePathname', () => { it('returns the correct pathname for the default locale', () => { mockCurrentLocale('en'); diff --git a/packages/next-intl/src/navigation/react-client/createNavigation.tsx b/packages/next-intl/src/navigation/react-client/createNavigation.tsx index 67ed49a7e..510954e9c 100644 --- a/packages/next-intl/src/navigation/react-client/createNavigation.tsx +++ b/packages/next-intl/src/navigation/react-client/createNavigation.tsx @@ -1,4 +1,4 @@ -import {useMemo} from 'react'; +import React, {ComponentProps, forwardRef, ReactElement, useMemo} from 'react'; import useLocale from '../../react-client/useLocale'; import { RoutingConfigLocalizedNavigation, @@ -20,22 +20,15 @@ export default function createNavigation< type Locale = AppLocales extends never ? string : AppLocales[number]; function getLocale() { - let locale; - try { - // eslint-disable-next-line react-hooks/rules-of-hooks -- Reading from context here is fine, since `redirect` must be called during render - locale = useLocale(); - } catch (e) { - if (process.env.NODE_ENV !== 'production') { - throw new Error( - '`redirect()` and `permanentRedirect()` can only be called during render. To redirect in an event handler or similar, you can use `useRouter()` instead.' - ); - } - throw e; - } - return locale as Locale; + // eslint-disable-next-line react-hooks/rules-of-hooks -- Reading from context here is fine, since this must always be called during render (redirect, useRouter) + return useLocale() as Locale; } - const {config, ...fns} = createSharedNavigationFns(getLocale, routing); + const { + Link: BaseLink, + config, + ...fns + } = createSharedNavigationFns(getLocale, routing); /** * Returns the pathname without a potential locale prefix. @@ -65,8 +58,17 @@ export default function createNavigation< ); } + type LinkProps = Omit, 'nodeRef'>; + function Link(props: LinkProps, ref: LinkProps['ref']) { + return ; + } + const LinkWithRef = forwardRef(Link) as ( + props: LinkProps & {ref?: LinkProps['ref']} + ) => ReactElement; + (LinkWithRef as any).displayName = 'Link'; + // TODO function useRouter() {} - return {...fns, usePathname, useRouter}; + return {...fns, Link: LinkWithRef, usePathname, useRouter}; } diff --git a/packages/next-intl/src/navigation/shared/BaseLink.tsx b/packages/next-intl/src/navigation/shared/BaseLink.tsx index fb1b81ff2..221264111 100644 --- a/packages/next-intl/src/navigation/shared/BaseLink.tsx +++ b/packages/next-intl/src/navigation/shared/BaseLink.tsx @@ -2,18 +2,16 @@ import NextLink from 'next/link'; import {usePathname} from 'next/navigation'; -import React, {ComponentProps, MouseEvent, forwardRef} from 'react'; +import React, {ComponentProps, MouseEvent} from 'react'; import useLocale from '../../react-client/useLocale'; import syncLocaleCookie from './syncLocaleCookie'; type Props = Omit, 'locale'> & { locale?: string; + nodeRef?: ComponentProps['ref']; }; -function BaseLink( - {href, locale, onClick, prefetch, ...rest}: Props, - ref: Props['ref'] -) { +function BaseLink({href, locale, nodeRef, onClick, prefetch, ...rest}: Props) { // The types aren't entirely correct here. Outside of Next.js // `useParams` can be called, but the return type is `null`. const pathname = usePathname() as ReturnType | null; @@ -37,7 +35,7 @@ function BaseLink( return ( ; + return ( + + ); } const LegacyBaseLinkWithRef = forwardRef(LegacyBaseLink); From 5f01ba40aad1de98db3b11ee4b17adfdb85bca32 Mon Sep 17 00:00:00 2001 From: Jan Amann Date: Thu, 5 Sep 2024 15:30:34 +0200 Subject: [PATCH 16/62] more tests for link --- .../react-client/createNavigation.test.tsx | 29 +++++++++++++++++-- 1 file changed, 27 insertions(+), 2 deletions(-) diff --git a/packages/next-intl/src/navigation/react-client/createNavigation.test.tsx b/packages/next-intl/src/navigation/react-client/createNavigation.test.tsx index 4b911e335..d9ffccd7d 100644 --- a/packages/next-intl/src/navigation/react-client/createNavigation.test.tsx +++ b/packages/next-intl/src/navigation/react-client/createNavigation.test.tsx @@ -1,4 +1,4 @@ -import {render, screen} from '@testing-library/react'; +import {fireEvent, render, screen} from '@testing-library/react'; import {useParams, usePathname as useNextPathname} from 'next/navigation'; import React from 'react'; import {beforeEach, describe, expect, it, vi} from 'vitest'; @@ -208,7 +208,7 @@ describe("localePrefix: 'as-needed'", () => { }); describe("localePrefix: 'never'", () => { - const {usePathname} = createNavigation({ + const {Link, usePathname} = createNavigation({ locales, defaultLocale, localePrefix: 'never' @@ -221,6 +221,31 @@ describe("localePrefix: 'never'", () => { render(); } + describe('Link', () => { + it('keeps the cookie value in sync', () => { + global.document.cookie = 'NEXT_LOCALE=en'; + render( + + Test + + ); + expect(document.cookie).toContain('NEXT_LOCALE=en'); + fireEvent.click(screen.getByRole('link', {name: 'Test'})); + expect(document.cookie).toContain('NEXT_LOCALE=de'); + }); + + it('updates the href when the query changes', () => { + const {rerender} = render(Test); + expect( + screen.getByRole('link', {name: 'Test'}).getAttribute('href') + ).toBe('/'); + rerender(Test); + expect( + screen.getByRole('link', {name: 'Test'}).getAttribute('href') + ).toBe('/?foo=bar'); + }); + }); + describe('usePathname', () => { it('returns the correct pathname for the default locale', () => { mockCurrentLocale('en'); From 45bc9fc1469b0b1ba1fb7996564c0a3ab4123f82 Mon Sep 17 00:00:00 2001 From: Jan Amann Date: Fri, 6 Sep 2024 10:42:36 +0200 Subject: [PATCH 17/62] Basic useRouter implementation --- .../react-client/createNavigation.test.tsx | 156 ++++++++++++++++-- .../react-client/createNavigation.tsx | 63 ++++++- 2 files changed, 199 insertions(+), 20 deletions(-) diff --git a/packages/next-intl/src/navigation/react-client/createNavigation.test.tsx b/packages/next-intl/src/navigation/react-client/createNavigation.test.tsx index d9ffccd7d..ad0ea67dd 100644 --- a/packages/next-intl/src/navigation/react-client/createNavigation.test.tsx +++ b/packages/next-intl/src/navigation/react-client/createNavigation.test.tsx @@ -1,19 +1,16 @@ import {fireEvent, render, screen} from '@testing-library/react'; -import {useParams, usePathname as useNextPathname} from 'next/navigation'; +import { + useParams, + usePathname as useNextPathname, + useRouter as useNextRouter +} from 'next/navigation'; import React from 'react'; import {beforeEach, describe, expect, it, vi} from 'vitest'; import {NextIntlClientProvider} from '../../react-client'; import {Pathnames} from '../../routing'; import createNavigation from './createNavigation'; -vi.mock('next/navigation', async () => { - const actual = await vi.importActual('next/navigation'); - return { - ...actual, - useParams: vi.fn(() => ({locale: 'en'})), - usePathname: vi.fn(() => '/') - }; -}); +vi.mock('next/navigation'); function mockCurrentLocale(locale: string) { vi.mocked(useParams<{locale: string}>).mockImplementation(() => ({ @@ -27,7 +24,17 @@ function mockCurrentPathname(string: string) { beforeEach(() => { mockCurrentLocale('en'); - mockCurrentLocale('/en'); + mockCurrentPathname('/en'); + + const router = { + push: vi.fn(), + replace: vi.fn(), + prefetch: vi.fn(), + back: vi.fn(), + forward: vi.fn(), + refresh: vi.fn() + }; + vi.mocked(useNextRouter).mockImplementation(() => router); }); const locales = ['en', 'de', 'ja'] as const; @@ -62,8 +69,19 @@ function getRenderPathname(usePathname: () => Return) { }; } +function getInvokeRouter(useRouter: () => Router) { + return function invokeRouter(cb: (router: Router) => void) { + function Component() { + const router = useRouter(); + cb(router); + return null; + } + render(); + }; +} + describe("localePrefix: 'always'", () => { - const {Link, usePathname} = createNavigation({ + const {Link, usePathname, useRouter} = createNavigation({ locales, defaultLocale, localePrefix: 'always' @@ -112,6 +130,48 @@ describe("localePrefix: 'always'", () => { }); }); + describe('useRouter', () => { + const invokeRouter = getInvokeRouter(useRouter); + + it('leaves unrelated router functionality in place', () => { + (['back', 'forward', 'refresh'] as const).forEach((method) => { + invokeRouter((router) => router[method]()); + expect(useNextRouter()[method]).toHaveBeenCalled(); + }); + }); + + describe.each(['push', 'replace'] as const)('`%s`', (method) => { + it('prefixes with the default locale', () => { + invokeRouter((router) => router[method]('/about')); + expect(useNextRouter()[method]).toHaveBeenCalledWith('/en/about'); + }); + + it('prefixes with a secondary locale', () => { + invokeRouter((router) => router[method]('/about', {locale: 'de'})); + expect(useNextRouter()[method]).toHaveBeenCalledWith('/de/about'); + }); + + it('passes through unknown options to the Next.js router', () => { + invokeRouter((router) => router[method]('/about', {scroll: true})); + expect(useNextRouter()[method]).toHaveBeenCalledWith('/en/about', { + scroll: true + }); + }); + }); + + describe('prefetch', () => { + it('prefixes with the default locale', () => { + invokeRouter((router) => router.prefetch('/about')); + expect(useNextRouter().prefetch).toHaveBeenCalledWith('/en/about'); + }); + + it('prefixes with a secondary locale', () => { + invokeRouter((router) => router.prefetch('/about', {locale: 'de'})); + expect(useNextRouter().prefetch).toHaveBeenCalledWith('/de/about'); + }); + }); + }); + describe('usePathname', () => { it('returns the correct pathname for the default locale', () => { mockCurrentLocale('en'); @@ -175,7 +235,7 @@ describe("localePrefix: 'always', custom `prefixes`", () => { }); describe("localePrefix: 'as-needed'", () => { - const {usePathname} = createNavigation({ + const {usePathname, useRouter} = createNavigation({ locales, defaultLocale, localePrefix: 'as-needed' @@ -188,6 +248,41 @@ describe("localePrefix: 'as-needed'", () => { render(); } + describe('useRouter', () => { + const invokeRouter = getInvokeRouter(useRouter); + + it('leaves unrelated router functionality in place', () => { + (['back', 'forward', 'refresh'] as const).forEach((method) => { + invokeRouter((router) => router[method]()); + expect(useNextRouter()[method]).toHaveBeenCalled(); + }); + }); + + describe.each(['push', 'replace'] as const)('`%s`', (method) => { + it('does not prefix the default locale', () => { + invokeRouter((router) => router[method]('/about')); + expect(useNextRouter()[method]).toHaveBeenCalledWith('/about'); + }); + + it('prefixes a secondary locale', () => { + invokeRouter((router) => router[method]('/about', {locale: 'de'})); + expect(useNextRouter()[method]).toHaveBeenCalledWith('/de/about'); + }); + }); + + describe('prefetch', () => { + it('prefixes with the default locale', () => { + invokeRouter((router) => router.prefetch('/about')); + expect(useNextRouter().prefetch).toHaveBeenCalledWith('/about'); + }); + + it('prefixes with a secondary locale', () => { + invokeRouter((router) => router.prefetch('/about', {locale: 'de'})); + expect(useNextRouter().prefetch).toHaveBeenCalledWith('/de/about'); + }); + }); + }); + describe('usePathname', () => { it('returns the correct pathname for the default locale', () => { mockCurrentLocale('en'); @@ -208,7 +303,7 @@ describe("localePrefix: 'as-needed'", () => { }); describe("localePrefix: 'never'", () => { - const {Link, usePathname} = createNavigation({ + const {Link, usePathname, useRouter} = createNavigation({ locales, defaultLocale, localePrefix: 'never' @@ -246,6 +341,41 @@ describe("localePrefix: 'never'", () => { }); }); + describe('useRouter', () => { + const invokeRouter = getInvokeRouter(useRouter); + + it('leaves unrelated router functionality in place', () => { + (['back', 'forward', 'refresh'] as const).forEach((method) => { + invokeRouter((router) => router[method]()); + expect(useNextRouter()[method]).toHaveBeenCalled(); + }); + }); + + describe.each(['push', 'replace'] as const)('`%s`', (method) => { + it('does not prefix the default locale', () => { + invokeRouter((router) => router[method]('/about')); + expect(useNextRouter()[method]).toHaveBeenCalledWith('/about'); + }); + + it('does not prefix a secondary locale', () => { + invokeRouter((router) => router[method]('/about', {locale: 'de'})); + expect(useNextRouter()[method]).toHaveBeenCalledWith('/about'); + }); + }); + + describe('prefetch', () => { + it('does not prefix the default locale', () => { + invokeRouter((router) => router.prefetch('/about')); + expect(useNextRouter().prefetch).toHaveBeenCalledWith('/about'); + }); + + it('does not prefix a secondary locale', () => { + invokeRouter((router) => router.prefetch('/about', {locale: 'de'})); + expect(useNextRouter().prefetch).toHaveBeenCalledWith('/about'); + }); + }); + }); + describe('usePathname', () => { it('returns the correct pathname for the default locale', () => { mockCurrentLocale('en'); diff --git a/packages/next-intl/src/navigation/react-client/createNavigation.tsx b/packages/next-intl/src/navigation/react-client/createNavigation.tsx index 510954e9c..82bae0216 100644 --- a/packages/next-intl/src/navigation/react-client/createNavigation.tsx +++ b/packages/next-intl/src/navigation/react-client/createNavigation.tsx @@ -1,3 +1,4 @@ +import {useRouter as useNextRouter} from 'next/navigation'; import React, {ComponentProps, forwardRef, ReactElement, useMemo} from 'react'; import useLocale from '../../react-client/useLocale'; import { @@ -19,7 +20,8 @@ export default function createNavigation< ) { type Locale = AppLocales extends never ? string : AppLocales[number]; - function getLocale() { + function useTypedLocale() { + // eslint-disable-next-line react-compiler/react-compiler // eslint-disable-next-line react-hooks/rules-of-hooks -- Reading from context here is fine, since this must always be called during render (redirect, useRouter) return useLocale() as Locale; } @@ -27,8 +29,9 @@ export default function createNavigation< const { Link: BaseLink, config, - ...fns - } = createSharedNavigationFns(getLocale, routing); + getPathname, + ...redirects + } = createSharedNavigationFns(useTypedLocale, routing); /** * Returns the pathname without a potential locale prefix. @@ -39,7 +42,7 @@ export default function createNavigation< ? string : keyof AppPathnames { const pathname = useBasePathname(config.localePrefix); - const locale = getLocale(); + const locale = useTypedLocale(); // @ts-expect-error -- Mirror the behavior from Next.js, where `null` is returned when `usePathname` is used outside of Next, but the types indicate that a string is always returned. return useMemo( @@ -67,8 +70,54 @@ export default function createNavigation< ) => ReactElement; (LinkWithRef as any).displayName = 'Link'; - // TODO - function useRouter() {} + function useRouter() { + const router = useNextRouter(); + const curLocale = useTypedLocale(); - return {...fns, Link: LinkWithRef, usePathname, useRouter}; + return useMemo(() => { + function createHandler< + Options, + Fn extends (href: string, options?: Options) => void + >(fn: Fn) { + return function handler( + href: string, + options?: Partial & {locale?: Locale} + ): void { + const {locale: nextLocale, ...rest} = options || {}; + + const pathname = getPathname({ + // @ts-expect-error -- This is fine + href, + locale: nextLocale || curLocale + }); + + const args: [href: string, options?: Options] = [pathname]; + if (Object.keys(rest).length > 0) { + // @ts-expect-error -- This is fine + args.push(rest); + } + + return fn(...args); + }; + } + + return { + ...router, + push: createHandler< + Parameters[1], + typeof router.push + >(router.push), + replace: createHandler< + Parameters[1], + typeof router.replace + >(router.replace), + prefetch: createHandler< + Parameters[1], + typeof router.prefetch + >(router.prefetch) + }; + }, [curLocale, router]); + } + + return {...redirects, Link: LinkWithRef, usePathname, useRouter, getPathname}; } From 4aa524d23a2d3e8d72fbdf7c0ee701359b53424b Mon Sep 17 00:00:00 2001 From: Jan Amann Date: Tue, 17 Sep 2024 15:30:01 +0200 Subject: [PATCH 18/62] Some simplification --- .../src/navigation/createNavigation.test.tsx | 12 +++++++ .../shared/createSharedNavigationFns.tsx | 31 +++++++------------ 2 files changed, 23 insertions(+), 20 deletions(-) diff --git a/packages/next-intl/src/navigation/createNavigation.test.tsx b/packages/next-intl/src/navigation/createNavigation.test.tsx index 34695a2ba..78717e39a 100644 --- a/packages/next-intl/src/navigation/createNavigation.test.tsx +++ b/packages/next-intl/src/navigation/createNavigation.test.tsx @@ -233,6 +233,18 @@ describe.each([ // eslint-disable-next-line no-unused-expressions () => getPathname({href: '/about'}); }); + + it('handles relative pathnames', () => { + // Not really useful, but we silently support this + expect(getPathname({locale: 'en', href: 'about'})).toBe('about'); + }); + + it('handles external pathnames', () => { + // Not really useful, but we silently support this + expect( + getPathname({locale: 'en', href: 'https://example.com/about'}) + ).toBe('https://example.com/about'); + }); }); describe.each([ diff --git a/packages/next-intl/src/navigation/shared/createSharedNavigationFns.tsx b/packages/next-intl/src/navigation/shared/createSharedNavigationFns.tsx index 801badc36..aa6681022 100644 --- a/packages/next-intl/src/navigation/shared/createSharedNavigationFns.tsx +++ b/packages/next-intl/src/navigation/shared/createSharedNavigationFns.tsx @@ -150,29 +150,20 @@ export default function createSharedNavigationFns< }); } - function baseRedirect( - fn: typeof nextRedirect | typeof nextPermanentRedirect, - href: Parameters[0]['href'], - ...args: ParametersExceptFirst + function getRedirectFn( + fn: typeof nextRedirect | typeof nextPermanentRedirect ) { - const locale = getLocale(); - const isChangingLocale = typeof href === 'object' && 'locale' in href; - return fn(getPathname({href, locale}, isChangingLocale), ...args); + return function redirectFn( + href: Parameters[0]['href'], + ...args: ParametersExceptFirst + ) { + const locale = getLocale(); + return fn(getPathname({href, locale}), ...args); + }; } - function redirect( - href: Parameters[0]['href'], - ...args: ParametersExceptFirst - ) { - return baseRedirect(nextRedirect, href, ...args); - } - - function permanentRedirect( - href: Parameters[0]['href'], - ...args: ParametersExceptFirst - ) { - return baseRedirect(nextPermanentRedirect, href, ...args); - } + const redirect = getRedirectFn(nextRedirect); + const permanentRedirect = getRedirectFn(nextPermanentRedirect); return { Link, From 6ad01670db51a91733a431759e475de6cd75c16c Mon Sep 17 00:00:00 2001 From: Jan Amann Date: Tue, 17 Sep 2024 16:27:02 +0200 Subject: [PATCH 19/62] Few more tests --- .../react-client/createNavigation.test.tsx | 125 ++++++++++++++++-- .../react-client/createNavigation.tsx | 13 +- 2 files changed, 123 insertions(+), 15 deletions(-) diff --git a/packages/next-intl/src/navigation/react-client/createNavigation.test.tsx b/packages/next-intl/src/navigation/react-client/createNavigation.test.tsx index ad0ea67dd..376e4a931 100644 --- a/packages/next-intl/src/navigation/react-client/createNavigation.test.tsx +++ b/packages/next-intl/src/navigation/react-client/createNavigation.test.tsx @@ -1,4 +1,5 @@ import {fireEvent, render, screen} from '@testing-library/react'; +import {PrefetchKind} from 'next/dist/client/components/router-reducer/router-reducer-types'; import { useParams, usePathname as useNextPathname, @@ -18,13 +19,17 @@ function mockCurrentLocale(locale: string) { })); } -function mockCurrentPathname(string: string) { - vi.mocked(useNextPathname).mockImplementation(() => string); +function mockLocation(pathname: string, basePath = '') { + vi.mocked(useNextPathname).mockReturnValue(pathname); + + delete (global.window as any).location; + global.window ??= Object.create(window); + (global.window as any).location = {pathname: basePath + pathname}; } beforeEach(() => { mockCurrentLocale('en'); - mockCurrentPathname('/en'); + mockLocation('/en'); const router = { push: vi.fn(), @@ -157,6 +162,18 @@ describe("localePrefix: 'always'", () => { scroll: true }); }); + + it('passes through absolute urls', () => { + invokeRouter((router) => router[method]('https://example.com')); + expect(useNextRouter()[method]).toHaveBeenCalledWith( + 'https://example.com' + ); + }); + + it('passes through relative urls', () => { + invokeRouter((router) => router[method]('about')); + expect(useNextRouter()[method]).toHaveBeenCalledWith('about'); + }); }); describe('prefetch', () => { @@ -166,8 +183,12 @@ describe("localePrefix: 'always'", () => { }); it('prefixes with a secondary locale', () => { - invokeRouter((router) => router.prefetch('/about', {locale: 'de'})); - expect(useNextRouter().prefetch).toHaveBeenCalledWith('/de/about'); + invokeRouter((router) => + router.prefetch('/about', {locale: 'de', kind: PrefetchKind.FULL}) + ); + expect(useNextRouter().prefetch).toHaveBeenCalledWith('/de/about', { + kind: 'full' + }); }); }); }); @@ -175,7 +196,7 @@ describe("localePrefix: 'always'", () => { describe('usePathname', () => { it('returns the correct pathname for the default locale', () => { mockCurrentLocale('en'); - mockCurrentPathname('/en/about'); + mockLocation('/en/about'); renderPathname(); screen.getByText('/about'); @@ -183,7 +204,7 @@ describe("localePrefix: 'always'", () => { it('returns the correct pathname for a secondary locale', () => { mockCurrentLocale('de'); - mockCurrentPathname('/de/about'); + mockLocation('/de/about'); renderPathname(); screen.getByText('/about'); @@ -191,6 +212,37 @@ describe("localePrefix: 'always'", () => { }); }); +describe("localePrefix: 'always', with `basePath`", () => { + const {useRouter} = createNavigation({ + locales, + defaultLocale, + localePrefix: 'always' + }); + + beforeEach(() => { + mockLocation('/en', '/base/path'); + }); + + describe('useRouter', () => { + const invokeRouter = getInvokeRouter(useRouter); + + it('can push', () => { + invokeRouter((router) => router.push('/test')); + expect(useNextRouter().push).toHaveBeenCalledWith('/en/test'); + }); + + it('can replace', () => { + invokeRouter((router) => router.replace('/test')); + expect(useNextRouter().replace).toHaveBeenCalledWith('/en/test'); + }); + + it('can prefetch', () => { + invokeRouter((router) => router.prefetch('/test')); + expect(useNextRouter().prefetch).toHaveBeenCalledWith('/en/test'); + }); + }); +}); + describe("localePrefix: 'always', with `pathnames`", () => { const {usePathname} = createNavigation({ locales, @@ -227,7 +279,7 @@ describe("localePrefix: 'always', custom `prefixes`", () => { describe('usePathname', () => { it('returns the correct pathname for a custom locale prefix', () => { mockCurrentLocale('en'); - mockCurrentPathname('/uk/about'); + mockLocation('/uk/about'); renderPathname(); screen.getByText('/about'); }); @@ -286,7 +338,7 @@ describe("localePrefix: 'as-needed'", () => { describe('usePathname', () => { it('returns the correct pathname for the default locale', () => { mockCurrentLocale('en'); - mockCurrentPathname('/about'); + mockLocation('/about'); renderPathname(); screen.getByText('/about'); @@ -294,7 +346,7 @@ describe("localePrefix: 'as-needed'", () => { it('returns the correct pathname for a secondary locale', () => { mockCurrentLocale('de'); - mockCurrentPathname('/de/about'); + mockLocation('/de/about'); renderPathname(); screen.getByText('/about'); @@ -363,6 +415,24 @@ describe("localePrefix: 'never'", () => { }); }); + it('keeps the cookie value in sync', () => { + document.cookie = 'NEXT_LOCALE=en'; + + invokeRouter((router) => router.push('/about', {locale: 'de'})); + expect(document.cookie).toContain('NEXT_LOCALE=de'); + + invokeRouter((router) => router.push('/test')); + expect(document.cookie).toContain('NEXT_LOCALE=de'); + + invokeRouter((router) => router.replace('/about', {locale: 'de'})); + expect(document.cookie).toContain('NEXT_LOCALE=de'); + + invokeRouter((router) => + router.prefetch('/about', {locale: 'ja', kind: PrefetchKind.AUTO}) + ); + expect(document.cookie).toContain('NEXT_LOCALE=ja'); + }); + describe('prefetch', () => { it('does not prefix the default locale', () => { invokeRouter((router) => router.prefetch('/about')); @@ -379,7 +449,7 @@ describe("localePrefix: 'never'", () => { describe('usePathname', () => { it('returns the correct pathname for the default locale', () => { mockCurrentLocale('en'); - mockCurrentPathname('/about'); + mockLocation('/about'); renderPathname(); screen.getByText('/about'); @@ -387,10 +457,41 @@ describe("localePrefix: 'never'", () => { it('returns the correct pathname for a secondary locale', () => { mockCurrentLocale('de'); - mockCurrentPathname('/about'); + mockLocation('/about'); renderPathname(); screen.getByText('/about'); }); }); }); + +describe("localePrefix: 'never', with `basePath`", () => { + const {useRouter} = createNavigation({ + locales, + defaultLocale, + localePrefix: 'never' + }); + + beforeEach(() => { + mockLocation('/en', '/base/path'); + }); + + describe('useRouter', () => { + const invokeRouter = getInvokeRouter(useRouter); + + it('can push', () => { + invokeRouter((router) => router.push('/test')); + expect(useNextRouter().push).toHaveBeenCalledWith('/test'); + }); + + it('can replace', () => { + invokeRouter((router) => router.replace('/test')); + expect(useNextRouter().replace).toHaveBeenCalledWith('/test'); + }); + + it('can prefetch', () => { + invokeRouter((router) => router.prefetch('/test')); + expect(useNextRouter().prefetch).toHaveBeenCalledWith('/test'); + }); + }); +}); diff --git a/packages/next-intl/src/navigation/react-client/createNavigation.tsx b/packages/next-intl/src/navigation/react-client/createNavigation.tsx index 82bae0216..8c6307af0 100644 --- a/packages/next-intl/src/navigation/react-client/createNavigation.tsx +++ b/packages/next-intl/src/navigation/react-client/createNavigation.tsx @@ -1,4 +1,7 @@ -import {useRouter as useNextRouter} from 'next/navigation'; +import { + useRouter as useNextRouter, + usePathname as useNextPathname +} from 'next/navigation'; import React, {ComponentProps, forwardRef, ReactElement, useMemo} from 'react'; import useLocale from '../../react-client/useLocale'; import { @@ -7,6 +10,7 @@ import { } from '../../routing/config'; import {Locales, Pathnames} from '../../routing/types'; import createSharedNavigationFns from '../shared/createSharedNavigationFns'; +import syncLocaleCookie from '../shared/syncLocaleCookie'; import {getRoute} from '../shared/utils'; import useBasePathname from './useBasePathname'; @@ -73,6 +77,7 @@ export default function createNavigation< function useRouter() { const router = useNextRouter(); const curLocale = useTypedLocale(); + const nextPathname = useNextPathname(); return useMemo(() => { function createHandler< @@ -97,7 +102,9 @@ export default function createNavigation< args.push(rest); } - return fn(...args); + fn(...args); + + syncLocaleCookie(nextPathname, curLocale, nextLocale); }; } @@ -116,7 +123,7 @@ export default function createNavigation< typeof router.prefetch >(router.prefetch) }; - }, [curLocale, router]); + }, [curLocale, nextPathname, router]); } return {...redirects, Link: LinkWithRef, usePathname, useRouter, getPathname}; From a4c6975c5e49a3d84942d0ea5a4193eaf8285665 Mon Sep 17 00:00:00 2001 From: Jan Amann Date: Tue, 17 Sep 2024 16:58:11 +0200 Subject: [PATCH 20/62] migrate app router playground, add base path test --- .../next.config.mjs | 1 + .../package.json | 3 +- .../src/components/ClientLink.tsx | 6 ++-- .../src/components/NavigationLink.tsx | 6 ++-- .../src/i18n/routing.ts | 4 +-- .../src/middleware.ts | 9 ++++-- .../tests/base-path.spec.ts | 22 +++++++++++++++ .../tests/getAlternateLinks.ts | 13 --------- .../tests/main.spec.ts | 19 ++----------- .../tests/trailing-slash.spec.ts | 2 +- .../tests/utils.ts | 28 +++++++++++++++++++ 11 files changed, 70 insertions(+), 43 deletions(-) create mode 100644 examples/example-app-router-playground/tests/base-path.spec.ts delete mode 100644 examples/example-app-router-playground/tests/getAlternateLinks.ts create mode 100644 examples/example-app-router-playground/tests/utils.ts diff --git a/examples/example-app-router-playground/next.config.mjs b/examples/example-app-router-playground/next.config.mjs index f30c3b7f9..c31a4af91 100644 --- a/examples/example-app-router-playground/next.config.mjs +++ b/examples/example-app-router-playground/next.config.mjs @@ -9,6 +9,7 @@ const withMdx = mdxPlugin(); export default withMdx( withNextIntl({ trailingSlash: process.env.TRAILING_SLASH === 'true', + basePath: process.env.BASE_PATH === 'true' ? '/base/path' : undefined, experimental: { staleTimes: { // Next.js 14.2 broke `locale-prefix-never.spec.ts`. diff --git a/examples/example-app-router-playground/package.json b/examples/example-app-router-playground/package.json index cdbc301d6..36c0dba48 100644 --- a/examples/example-app-router-playground/package.json +++ b/examples/example-app-router-playground/package.json @@ -4,10 +4,11 @@ "scripts": { "dev": "next dev", "lint": "eslint src && tsc", - "test": "pnpm run test:playwright:main && pnpm run test:playwright:locale-prefix-never && pnpm run test:playwright:trailing-slash && pnpm run test:jest", + "test": "pnpm run test:playwright:main && pnpm run test:playwright:locale-prefix-never && pnpm run test:playwright:trailing-slash && pnpm run test:jest && test:playwright:base-path", "test:playwright:main": "TEST_MATCH=main.spec.ts playwright test", "test:playwright:locale-prefix-never": "NEXT_PUBLIC_LOCALE_PREFIX=never pnpm build && TEST_MATCH=locale-prefix-never.spec.ts playwright test", "test:playwright:trailing-slash": "TRAILING_SLASH=true pnpm build && TEST_MATCH=trailing-slash.spec.ts playwright test", + "test:playwright:base-path": "BASE_PATH=true pnpm build && TEST_MATCH=base-path.spec.ts playwright test", "test:jest": "jest", "build": "next build", "start": "next start", diff --git a/examples/example-app-router-playground/src/components/ClientLink.tsx b/examples/example-app-router-playground/src/components/ClientLink.tsx index 108e23321..c0f6c79b4 100644 --- a/examples/example-app-router-playground/src/components/ClientLink.tsx +++ b/examples/example-app-router-playground/src/components/ClientLink.tsx @@ -1,10 +1,8 @@ 'use client'; import {ComponentProps} from 'react'; -import {Link, Pathnames} from '@/i18n/routing'; +import {Link} from '@/i18n/routing'; -export default function NavigationLink( - props: ComponentProps> -) { +export default function NavigationLink(props: ComponentProps) { return ; } diff --git a/examples/example-app-router-playground/src/components/NavigationLink.tsx b/examples/example-app-router-playground/src/components/NavigationLink.tsx index 55d4302b0..085cc9141 100644 --- a/examples/example-app-router-playground/src/components/NavigationLink.tsx +++ b/examples/example-app-router-playground/src/components/NavigationLink.tsx @@ -2,12 +2,12 @@ import {useSelectedLayoutSegment} from 'next/navigation'; import {ComponentProps} from 'react'; -import {Link, Pathnames} from '@/i18n/routing'; +import {Link} from '@/i18n/routing'; -export default function NavigationLink({ +export default function NavigationLink({ href, ...rest -}: ComponentProps>) { +}: ComponentProps) { const selectedLayoutSegment = useSelectedLayoutSegment(); const pathname = selectedLayoutSegment ? `/${selectedLayoutSegment}` : '/'; const isActive = pathname === href; diff --git a/examples/example-app-router-playground/src/i18n/routing.ts b/examples/example-app-router-playground/src/i18n/routing.ts index e58c59d49..64143a6c0 100644 --- a/examples/example-app-router-playground/src/i18n/routing.ts +++ b/examples/example-app-router-playground/src/i18n/routing.ts @@ -1,4 +1,4 @@ -import {createLocalizedPathnamesNavigation} from 'next-intl/navigation'; +import {createNavigation} from 'next-intl/navigation'; import {defineRouting} from 'next-intl/routing'; export const routing = defineRouting({ @@ -44,4 +44,4 @@ export type Pathnames = keyof typeof routing.pathnames; export type Locale = (typeof routing.locales)[number]; export const {Link, getPathname, redirect, usePathname, useRouter} = - createLocalizedPathnamesNavigation(routing); + createNavigation(routing); diff --git a/examples/example-app-router-playground/src/middleware.ts b/examples/example-app-router-playground/src/middleware.ts index 9844a0d5d..7468bf33d 100644 --- a/examples/example-app-router-playground/src/middleware.ts +++ b/examples/example-app-router-playground/src/middleware.ts @@ -4,6 +4,11 @@ import {routing} from './i18n/routing'; export default createMiddleware(routing); export const config = { - // Skip all paths that should not be internationalized - matcher: ['/((?!_next|.*\\..*).*)'] + matcher: [ + // Skip all paths that should not be internationalized + '/((?!_next|.*\\..*).*)', + + // Necessary for base path to work + '/' + ] }; diff --git a/examples/example-app-router-playground/tests/base-path.spec.ts b/examples/example-app-router-playground/tests/base-path.spec.ts new file mode 100644 index 000000000..502e24443 --- /dev/null +++ b/examples/example-app-router-playground/tests/base-path.spec.ts @@ -0,0 +1,22 @@ +import {test as it, expect} from '@playwright/test'; +import {assertLocaleCookieValue} from './utils'; + +it('can use the router', async ({page}) => { + await page.goto('/base/path'); + await assertLocaleCookieValue(page, 'en', {path: '/base/path'}); + + await page.getByRole('button', {name: 'Go to nested page'}).click(); + await expect(page).toHaveURL('/base/path/nested'); + await page.getByRole('link', {name: 'Home'}).click(); + await page.getByRole('link', {name: 'Switch to German'}).click(); + + await expect(page).toHaveURL('/base/path/de'); + assertLocaleCookieValue(page, 'de', {path: '/base/path'}); + await page.getByRole('button', {name: 'Go to nested page'}).click(); + await expect(page).toHaveURL('/base/path/de/verschachtelt'); + await page.getByRole('link', {name: 'Start'}).click(); + await page.getByRole('link', {name: 'Zu Englisch wechseln'}).click(); + + await expect(page).toHaveURL('/base/path'); + assertLocaleCookieValue(page, 'en', {path: '/base/path'}); +}); diff --git a/examples/example-app-router-playground/tests/getAlternateLinks.ts b/examples/example-app-router-playground/tests/getAlternateLinks.ts deleted file mode 100644 index 0b9c58d37..000000000 --- a/examples/example-app-router-playground/tests/getAlternateLinks.ts +++ /dev/null @@ -1,13 +0,0 @@ -import {APIResponse} from '@playwright/test'; - -export default async function getAlternateLinks(response: APIResponse) { - return ( - response - .headers() - .link.split(', ') - // On CI, Playwright uses a different host somehow - .map((cur) => cur.replace(/0\.0\.0\.0/g, 'localhost')) - // Normalize ports - .map((cur) => cur.replace(/localhost:\d{4}/g, 'localhost:3000')) - ); -} diff --git a/examples/example-app-router-playground/tests/main.spec.ts b/examples/example-app-router-playground/tests/main.spec.ts index e32eb46c4..61d20d080 100644 --- a/examples/example-app-router-playground/tests/main.spec.ts +++ b/examples/example-app-router-playground/tests/main.spec.ts @@ -1,23 +1,8 @@ -import {test as it, expect, Page, BrowserContext} from '@playwright/test'; -import getAlternateLinks from './getAlternateLinks'; +import {test as it, expect, BrowserContext} from '@playwright/test'; +import {getAlternateLinks, assertLocaleCookieValue} from './utils'; const describe = it.describe; -async function assertLocaleCookieValue( - page: Page, - value: string, - otherProps?: Record -) { - const cookie = (await page.context().cookies()).find( - (cur) => cur.name === 'NEXT_LOCALE' - ); - expect(cookie).toMatchObject({ - name: 'NEXT_LOCALE', - value, - ...otherProps - }); -} - function getPageLoadTracker(context: BrowserContext) { const state = {numPageLoads: 0}; diff --git a/examples/example-app-router-playground/tests/trailing-slash.spec.ts b/examples/example-app-router-playground/tests/trailing-slash.spec.ts index f1cc5458b..cdcea2132 100644 --- a/examples/example-app-router-playground/tests/trailing-slash.spec.ts +++ b/examples/example-app-router-playground/tests/trailing-slash.spec.ts @@ -1,5 +1,5 @@ import {test as it, expect} from '@playwright/test'; -import getAlternateLinks from './getAlternateLinks'; +import {getAlternateLinks} from './utils'; it('redirects to a locale prefix correctly', async ({request}) => { const response = await request.get('/', { diff --git a/examples/example-app-router-playground/tests/utils.ts b/examples/example-app-router-playground/tests/utils.ts new file mode 100644 index 000000000..259b175bf --- /dev/null +++ b/examples/example-app-router-playground/tests/utils.ts @@ -0,0 +1,28 @@ +import {APIResponse, expect, Page} from '@playwright/test'; + +export async function getAlternateLinks(response: APIResponse) { + return ( + response + .headers() + .link.split(', ') + // On CI, Playwright uses a different host somehow + .map((cur) => cur.replace(/0\.0\.0\.0/g, 'localhost')) + // Normalize ports + .map((cur) => cur.replace(/localhost:\d{4}/g, 'localhost:3000')) + ); +} + +export async function assertLocaleCookieValue( + page: Page, + value: string, + otherProps?: Record +) { + const cookie = (await page.context().cookies()).find( + (cur) => cur.name === 'NEXT_LOCALE' + ); + expect(cookie).toMatchObject({ + name: 'NEXT_LOCALE', + value, + ...otherProps + }); +} From 6a8c95847210ddffe6bb666cf9945aedafbd7424 Mon Sep 17 00:00:00 2001 From: Jan Amann Date: Tue, 17 Sep 2024 17:00:42 +0200 Subject: [PATCH 21/62] comment --- packages/next-intl/src/navigation/shared/utils.tsx | 3 +++ 1 file changed, 3 insertions(+) diff --git a/packages/next-intl/src/navigation/shared/utils.tsx b/packages/next-intl/src/navigation/shared/utils.tsx index 93046cab9..8cd904677 100644 --- a/packages/next-intl/src/navigation/shared/utils.tsx +++ b/packages/next-intl/src/navigation/shared/utils.tsx @@ -26,12 +26,15 @@ type HrefOrHrefWithParamsImpl = : // No params Pathname | ({pathname: Pathname} & Other); +// For `Link` export type HrefOrUrlObjectWithParams = HrefOrHrefWithParamsImpl< Pathname, Omit >; export type QueryParams = Record; + +// For `getPathname` (hence also its consumers: `redirect`, `useRouter`, …) export type HrefOrHrefWithParams = HrefOrHrefWithParamsImpl< Pathname, {query?: QueryParams} From 52aaeebaca133f2387d3dceca4c9a214aba2b038 Mon Sep 17 00:00:00 2001 From: Jan Amann Date: Tue, 17 Sep 2024 17:13:28 +0200 Subject: [PATCH 22/62] Migrate `getPathname` in playground --- .../app/[locale]/news/[articleId]/page.tsx | 26 +++++++++---------- 1 file changed, 12 insertions(+), 14 deletions(-) diff --git a/examples/example-app-router-playground/src/app/[locale]/news/[articleId]/page.tsx b/examples/example-app-router-playground/src/app/[locale]/news/[articleId]/page.tsx index 46c94b8ad..7596bfb44 100644 --- a/examples/example-app-router-playground/src/app/[locale]/news/[articleId]/page.tsx +++ b/examples/example-app-router-playground/src/app/[locale]/news/[articleId]/page.tsx @@ -1,6 +1,6 @@ import {Metadata} from 'next'; import {useTranslations} from 'next-intl'; -import {getPathname, routing, Locale} from '@/i18n/routing'; +import {getPathname, Locale} from '@/i18n/routing'; type Props = { params: { @@ -10,19 +10,17 @@ type Props = { }; export async function generateMetadata({params}: Props): Promise { - let canonical = getPathname({ - href: { - pathname: '/news/[articleId]', - params: {articleId: params.articleId} - }, - locale: params.locale - }); - - if (params.locale !== routing.defaultLocale) { - canonical = '/' + params.locale + canonical; - } - - return {alternates: {canonical}}; + return { + alternates: { + canonical: getPathname({ + href: { + pathname: '/news/[articleId]', + params: {articleId: params.articleId} + }, + locale: params.locale + }) + } + }; } export default function NewsArticle({params}: Props) { From b8018334112dcd512190e5b79f6263d23eae56e3 Mon Sep 17 00:00:00 2001 From: Jan Amann Date: Tue, 17 Sep 2024 17:20:44 +0200 Subject: [PATCH 23/62] no jsdoc --- .../src/navigation/react-client/createNavigation.tsx | 5 ----- 1 file changed, 5 deletions(-) diff --git a/packages/next-intl/src/navigation/react-client/createNavigation.tsx b/packages/next-intl/src/navigation/react-client/createNavigation.tsx index 8c6307af0..9041cea28 100644 --- a/packages/next-intl/src/navigation/react-client/createNavigation.tsx +++ b/packages/next-intl/src/navigation/react-client/createNavigation.tsx @@ -37,11 +37,6 @@ export default function createNavigation< ...redirects } = createSharedNavigationFns(useTypedLocale, routing); - /** - * Returns the pathname without a potential locale prefix. - * - * @see https://next-intl-docs.vercel.app/docs/routing/navigation#usepathname - */ function usePathname(): [AppPathnames] extends [never] ? string : keyof AppPathnames { From 99a144a4f8ff2a45aa2fb4654b662def85d1639e Mon Sep 17 00:00:00 2001 From: Jan Amann Date: Tue, 17 Sep 2024 17:23:36 +0200 Subject: [PATCH 24/62] Fix invocation --- examples/example-app-router-playground/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/example-app-router-playground/package.json b/examples/example-app-router-playground/package.json index 36c0dba48..e1ea55c25 100644 --- a/examples/example-app-router-playground/package.json +++ b/examples/example-app-router-playground/package.json @@ -4,7 +4,7 @@ "scripts": { "dev": "next dev", "lint": "eslint src && tsc", - "test": "pnpm run test:playwright:main && pnpm run test:playwright:locale-prefix-never && pnpm run test:playwright:trailing-slash && pnpm run test:jest && test:playwright:base-path", + "test": "pnpm run test:playwright:main && pnpm run test:playwright:locale-prefix-never && pnpm run test:playwright:trailing-slash && pnpm run test:jest && pnpm run test:playwright:base-path", "test:playwright:main": "TEST_MATCH=main.spec.ts playwright test", "test:playwright:locale-prefix-never": "NEXT_PUBLIC_LOCALE_PREFIX=never pnpm build && TEST_MATCH=locale-prefix-never.spec.ts playwright test", "test:playwright:trailing-slash": "TRAILING_SLASH=true pnpm build && TEST_MATCH=trailing-slash.spec.ts playwright test", From 92f181d8252a0eafd5505ab53d5d36ae263f1cf5 Mon Sep 17 00:00:00 2001 From: Jan Amann Date: Wed, 18 Sep 2024 08:28:36 +0200 Subject: [PATCH 25/62] fix test --- examples/example-app-router-playground/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/example-app-router-playground/package.json b/examples/example-app-router-playground/package.json index e1ea55c25..a8f5cc700 100644 --- a/examples/example-app-router-playground/package.json +++ b/examples/example-app-router-playground/package.json @@ -8,7 +8,7 @@ "test:playwright:main": "TEST_MATCH=main.spec.ts playwright test", "test:playwright:locale-prefix-never": "NEXT_PUBLIC_LOCALE_PREFIX=never pnpm build && TEST_MATCH=locale-prefix-never.spec.ts playwright test", "test:playwright:trailing-slash": "TRAILING_SLASH=true pnpm build && TEST_MATCH=trailing-slash.spec.ts playwright test", - "test:playwright:base-path": "BASE_PATH=true pnpm build && TEST_MATCH=base-path.spec.ts playwright test", + "test:playwright:base-path": "BASE_PATH=true pnpm build && BASE_PATH=true TEST_MATCH=base-path.spec.ts playwright test", "test:jest": "jest", "build": "next build", "start": "next start", From ee7e2eb6b7625a9ef7ca8b703af021969a24d499 Mon Sep 17 00:00:00 2001 From: Jan Amann Date: Wed, 18 Sep 2024 08:51:46 +0200 Subject: [PATCH 26/62] refactor playwright tests to use cases --- .../example-app-router-playground/next.config.mjs | 4 ++-- examples/example-app-router-playground/package.json | 7 ++----- .../example-app-router-playground/runPlaywright.mjs | 11 +++++++++++ .../example-app-router-playground/src/i18n/routing.ts | 2 +- 4 files changed, 16 insertions(+), 8 deletions(-) create mode 100644 examples/example-app-router-playground/runPlaywright.mjs diff --git a/examples/example-app-router-playground/next.config.mjs b/examples/example-app-router-playground/next.config.mjs index c31a4af91..14a138167 100644 --- a/examples/example-app-router-playground/next.config.mjs +++ b/examples/example-app-router-playground/next.config.mjs @@ -8,8 +8,8 @@ const withMdx = mdxPlugin(); export default withMdx( withNextIntl({ - trailingSlash: process.env.TRAILING_SLASH === 'true', - basePath: process.env.BASE_PATH === 'true' ? '/base/path' : undefined, + trailingSlash: process.env.USE_CASE === 'trailing-slash', + basePath: process.env.USE_CASE === 'base-path' ? '/base/path' : undefined, experimental: { staleTimes: { // Next.js 14.2 broke `locale-prefix-never.spec.ts`. diff --git a/examples/example-app-router-playground/package.json b/examples/example-app-router-playground/package.json index a8f5cc700..7a970d61c 100644 --- a/examples/example-app-router-playground/package.json +++ b/examples/example-app-router-playground/package.json @@ -4,11 +4,8 @@ "scripts": { "dev": "next dev", "lint": "eslint src && tsc", - "test": "pnpm run test:playwright:main && pnpm run test:playwright:locale-prefix-never && pnpm run test:playwright:trailing-slash && pnpm run test:jest && pnpm run test:playwright:base-path", - "test:playwright:main": "TEST_MATCH=main.spec.ts playwright test", - "test:playwright:locale-prefix-never": "NEXT_PUBLIC_LOCALE_PREFIX=never pnpm build && TEST_MATCH=locale-prefix-never.spec.ts playwright test", - "test:playwright:trailing-slash": "TRAILING_SLASH=true pnpm build && TEST_MATCH=trailing-slash.spec.ts playwright test", - "test:playwright:base-path": "BASE_PATH=true pnpm build && BASE_PATH=true TEST_MATCH=base-path.spec.ts playwright test", + "test": "pnpm test:jest && node test.mjs", + "test:playwright": "node runPlaywright.mjs", "test:jest": "jest", "build": "next build", "start": "next start", diff --git a/examples/example-app-router-playground/runPlaywright.mjs b/examples/example-app-router-playground/runPlaywright.mjs new file mode 100644 index 000000000..12129d0b1 --- /dev/null +++ b/examples/example-app-router-playground/runPlaywright.mjs @@ -0,0 +1,11 @@ +import {execSync} from 'child_process'; + +const useCases = ['main', 'locale-prefix-never', 'trailing-slash', 'base-path']; + +for (const useCase of useCases) { + // eslint-disable-next-line no-console + console.log(`Running tests for use case: ${useCase}`); + + const command = `USE_CASE=${useCase} pnpm build && USE_CASE=${useCase} TEST_MATCH=${useCase}.spec.ts playwright test`; + execSync(command, {stdio: 'inherit'}); +} diff --git a/examples/example-app-router-playground/src/i18n/routing.ts b/examples/example-app-router-playground/src/i18n/routing.ts index 64143a6c0..fee57c938 100644 --- a/examples/example-app-router-playground/src/i18n/routing.ts +++ b/examples/example-app-router-playground/src/i18n/routing.ts @@ -5,7 +5,7 @@ export const routing = defineRouting({ locales: ['en', 'de', 'es', 'ja'], defaultLocale: 'en', localePrefix: - process.env.NEXT_PUBLIC_LOCALE_PREFIX === 'never' + process.env.USE_CASE === 'locale-prefix-never' ? 'never' : { mode: 'as-needed', From ba7acf4451637eb520a0d47cdf9b62cc1a6b3f86 Mon Sep 17 00:00:00 2001 From: Jan Amann Date: Fri, 20 Sep 2024 12:29:23 +0200 Subject: [PATCH 27/62] first bit of domains support --- .../src/navigation/createNavigation.test.tsx | 88 ++++++++++++++++++- .../shared/createSharedNavigationFns.tsx | 24 +++-- .../next-intl/src/navigation/shared/utils.tsx | 72 ++++++++++----- 3 files changed, 151 insertions(+), 33 deletions(-) diff --git a/packages/next-intl/src/navigation/createNavigation.test.tsx b/packages/next-intl/src/navigation/createNavigation.test.tsx index 78717e39a..cc49ed007 100644 --- a/packages/next-intl/src/navigation/createNavigation.test.tsx +++ b/packages/next-intl/src/navigation/createNavigation.test.tsx @@ -8,7 +8,7 @@ import { import React from 'react'; import {renderToString} from 'react-dom/server'; import {it, describe, vi, expect, beforeEach} from 'vitest'; -import {defineRouting, Pathnames} from '../routing'; +import {defineRouting, DomainsConfig, Pathnames} from '../routing'; import {getRequestLocale} from '../server/react-server/RequestLocale'; import createNavigationClient from './react-client/createNavigation'; import createNavigationServer from './react-server/createNavigation'; @@ -47,6 +47,18 @@ beforeEach(() => { const locales = ['en', 'de', 'ja'] as const; const defaultLocale = 'en' as const; +const domains: DomainsConfig = [ + { + defaultLocale: 'en', + domain: 'example.com' + }, + { + defaultLocale: 'de', + domain: 'example.de', + locales: ['de', 'en'] + } +]; + const pathnames = { '/': '/', '/about': { @@ -672,6 +684,80 @@ describe.each([ // describe("localePrefix: 'as-needed', with `domains`", () => {}) // describe("localePrefix: 'never', with `domains`", () => {}) // describe("localePrefix: 'always', with `domains`", () => {}) + describe("localePrefix: 'as-needed', with `domains`", () => { + const {Link, getPathname, permanentRedirect, redirect} = createNavigation({ + locales, + defaultLocale, + domains, + localePrefix: 'as-needed' + }); + + describe('Link', () => { + it('renders a prefix during SSR even for the default locale', () => { + // (see comment in source for reasoning) + const markup = renderToString(About); + expect(markup).toContain('href="/en/about"'); + }); + + }); + + describe('getPathname', () => { + it('does not add a prefix for the default locale', () => { + expect( + getPathname({locale: 'en', href: '/about', domain: 'example.com'}) + ).toBe('/about'); + expect( + getPathname({locale: 'de', href: '/about', domain: 'example.de'}) + ).toBe('/about'); + }); + + it('adds a prefix for a secondary locale', () => { + expect( + getPathname({locale: 'de', href: '/about', domain: 'example.com'}) + ).toBe('/de/about'); + expect( + getPathname({locale: 'en', href: '/about', domain: 'example.de'}) + ).toBe('/en/about'); + }); + + it('prints a warning when no domain is provided', () => { + const originalConsoleError = globalThis.console.error; + globalThis.console.error = vi.fn(); + getPathname({locale: 'de', href: '/about'}); + expect(globalThis.console.error).toHaveBeenCalledWith( + "You're using a routing configuration with `localePrefix: 'as-needed'` in combination with `domains`. In order to compute a correct pathname, you need to provide a `domain` parameter." + ); + globalThis.console.error = originalConsoleError; + }); + + it('prints a warning when an unknown domain is provided', () => { + const originalConsoleError = globalThis.console.error; + globalThis.console.error = vi.fn(); + getPathname({locale: 'de', href: '/about', domain: 'example.org'}); + expect(globalThis.console.error).toHaveBeenCalledWith( + 'Domain "example.org" not found in the routing configuration. Available domains: example.com, example.de' + ); + globalThis.console.error = originalConsoleError; + }); + }); + + describe.each([ + ['redirect', redirect, nextRedirect], + ['permanentRedirect', permanentRedirect, nextPermanentRedirect] + ])('%s', (_, redirectFn, nextRedirectFn) => { + it('adds a prefix even for the default locale', () => { + // (see comment in source for reasoning) + runInRender(() => redirectFn('/')); + expect(nextRedirectFn).toHaveBeenLastCalledWith('/en'); + }); + + it('adds a prefix when currently on a secondary locale', () => { + mockCurrentLocale('de'); + runInRender(() => redirectFn('/')); + expect(nextRedirectFn).toHaveBeenLastCalledWith('/de'); + }); + }); + }); describe("localePrefix: 'never'", () => { const {Link, getPathname, permanentRedirect, redirect} = createNavigation({ diff --git a/packages/next-intl/src/navigation/shared/createSharedNavigationFns.tsx b/packages/next-intl/src/navigation/shared/createSharedNavigationFns.tsx index aa6681022..66bb99202 100644 --- a/packages/next-intl/src/navigation/shared/createSharedNavigationFns.tsx +++ b/packages/next-intl/src/navigation/shared/createSharedNavigationFns.tsx @@ -53,6 +53,15 @@ export default function createSharedNavigationFns< ? undefined : AppPathnames; + // This combination requires that the current host is known in order to + // compute a correct pathname. Since that can only be achieved by reading from + // headers, this would break static rendering. Therefore, as a workaround we + // always add a prefix in this case to be on the safe side. The downside is + // that the user might get redirected again if the middleware detects that the + // prefix is not needed. + const forcePrefixSsr = + config.localePrefix.mode === 'as-needed' && 'domains' in config; + type LinkProps = Omit< ComponentProps, 'href' | 'localePrefix' @@ -86,7 +95,7 @@ export default function createSharedNavigationFns< // @ts-expect-error -- This is ok href: pathnames == null ? pathname : {pathname, params} }, - locale != null + locale != null || forcePrefixSsr ) : pathname; @@ -106,6 +115,7 @@ export default function createSharedNavigationFns< // New: getPathname is available for shared pathnames function getPathname( { + domain, href, locale }: { @@ -113,6 +123,8 @@ export default function createSharedNavigationFns< href: [AppPathnames] extends [never] ? string | {pathname: string; query?: QueryParams} : HrefOrHrefWithParams; + /** In case you're using `localePrefix: 'as-necessary'` in combination with `domains`, the `defaultLocale` can differ by domain and therefore the locales that need to be prefixed can differ as well. For this particular case, this parameter should be provided in order to compute the correct pathname. Note that the actual domain is not part of the result, but only the pathname is returned. */ + domain?: string; }, /** @private */ _forcePrefix?: boolean @@ -142,12 +154,7 @@ export default function createSharedNavigationFns< // would be reading `host`, but that breaks SSG. If you want // to get the first shot right, pass a `domain` here (then // the user opts into dynamic rendering) - return applyPathnamePrefix({ - pathname, - locale, - routing: config, - force: _forcePrefix - }); + return applyPathnamePrefix(pathname, locale, config, domain, _forcePrefix); } function getRedirectFn( @@ -158,7 +165,8 @@ export default function createSharedNavigationFns< ...args: ParametersExceptFirst ) { const locale = getLocale(); - return fn(getPathname({href, locale}), ...args); + + return fn(getPathname({href, locale}, forcePrefixSsr), ...args); }; } diff --git a/packages/next-intl/src/navigation/shared/utils.tsx b/packages/next-intl/src/navigation/shared/utils.tsx index 8cd904677..ae61bcda8 100644 --- a/packages/next-intl/src/navigation/shared/utils.tsx +++ b/packages/next-intl/src/navigation/shared/utils.tsx @@ -21,10 +21,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); // For `Link` export type HrefOrUrlObjectWithParams = HrefOrHrefWithParamsImpl< @@ -217,29 +217,53 @@ export function getBasePath( } } -export function applyPathnamePrefix(params: { - pathname: string; - locale: Locales[number]; +export function applyPathnamePrefix( + pathname: string, + locale: Locales[number], routing: Pick, 'localePrefix' | 'domains'> & - Partial, 'defaultLocale'>>; - force?: boolean; -}): string { - const {mode} = params.routing.localePrefix; - const shouldPrefix = - isLocalizableHref(params.pathname) && - (params.force || - mode === 'always' || - (mode === 'as-needed' && - params.routing.defaultLocale !== params.locale && - // TODO: Rework - !params.routing.domains)); + Partial, 'defaultLocale'>>, + domain?: string, + force?: boolean +): string { + const {mode} = routing.localePrefix; + + let shouldPrefix; + if (isLocalizableHref(pathname)) { + if (force || mode === 'always') { + shouldPrefix = true; + } else if (mode === 'as-needed') { + let {defaultLocale} = routing; + + if (routing.domains) { + const domainConfig = routing.domains.find( + (cur) => cur.domain === domain + ); + if (domainConfig) { + defaultLocale = domainConfig.defaultLocale; + } else if (process.env.NODE_ENV !== 'production') { + if (!domain) { + console.error( + "You're using a routing configuration with `localePrefix: 'as-needed'` in combination with `domains`. In order to compute a correct pathname, you need to provide a `domain` parameter." + // TODO: Link to docs. q: which apis are affected? + // solution for user: provide domain manually, read from host header + ); + } else { + console.error( + `Domain "${domain}" not found in the routing configuration. Available domains: ${routing.domains + .map((cur) => cur.domain) + .join(', ')}` + ); + } + } + } + + shouldPrefix = defaultLocale !== locale; + } + } return shouldPrefix - ? prefixPathname( - getLocalePrefix(params.locale, params.routing.localePrefix), - params.pathname - ) - : params.pathname; + ? prefixPathname(getLocalePrefix(locale, routing.localePrefix), pathname) + : pathname; } export function validateReceivedConfig( From 604d56c50cbfa8b7f75048d5b70643badd1568e7 Mon Sep 17 00:00:00 2001 From: Jan Amann Date: Fri, 20 Sep 2024 15:01:44 +0200 Subject: [PATCH 28/62] domain progress with hydrating link --- .../src/navigation/createNavigation.test.tsx | 36 +++++++++++++++- .../src/navigation/shared/BaseLink.tsx | 41 +++++++++++++++---- .../shared/createSharedNavigationFns.tsx | 41 ++++++++++++++++--- .../next-intl/src/navigation/shared/utils.tsx | 6 ++- 4 files changed, 108 insertions(+), 16 deletions(-) diff --git a/packages/next-intl/src/navigation/createNavigation.test.tsx b/packages/next-intl/src/navigation/createNavigation.test.tsx index cc49ed007..32733ba2e 100644 --- a/packages/next-intl/src/navigation/createNavigation.test.tsx +++ b/packages/next-intl/src/navigation/createNavigation.test.tsx @@ -40,8 +40,15 @@ function mockCurrentLocale(locale: string) { })); } +function mockLocation(location: Partial) { + delete (global.window as any).location; + global.window ??= Object.create(window); + (global.window as any).location = location; +} + beforeEach(() => { mockCurrentLocale('en'); + mockLocation({host: 'localhost:3000'}); }); const locales = ['en', 'de', 'ja'] as const; @@ -133,7 +140,7 @@ describe.each([ expect(markup).toContain('hrefLang="de"'); }); - it('renders an object href', () => { + it('renders an object href with an external host', () => { render( { + mockLocation({host: 'example.com'}); + render(About); + expect( + screen.getByRole('link', {name: 'About'}).getAttribute('href') + ).toBe('/about'); + }); + + it('renders a prefix when currently on a secondary locale', () => { + mockLocation({host: 'example.de'}); + mockCurrentLocale('en'); + render(About); + expect( + screen.getByRole('link', {name: 'About'}).getAttribute('href') + ).toBe('/en/about'); + }); + + it('renders a prefix when currently on a secondary locale and linking to the default locale', () => { + mockLocation({host: 'example.de'}); + mockCurrentLocale('en'); + const markup = renderToString( + + About + + ); + expect(markup).toContain('href="/de/about"'); + }); }); describe('getPathname', () => { diff --git a/packages/next-intl/src/navigation/shared/BaseLink.tsx b/packages/next-intl/src/navigation/shared/BaseLink.tsx index 221264111..d415e7d15 100644 --- a/packages/next-intl/src/navigation/shared/BaseLink.tsx +++ b/packages/next-intl/src/navigation/shared/BaseLink.tsx @@ -2,23 +2,42 @@ import NextLink from 'next/link'; import {usePathname} from 'next/navigation'; -import React, {ComponentProps, MouseEvent} from 'react'; +import React, {ComponentProps, MouseEvent, useEffect, useState} from 'react'; import useLocale from '../../react-client/useLocale'; import syncLocaleCookie from './syncLocaleCookie'; type Props = Omit, 'locale'> & { locale?: string; nodeRef?: ComponentProps['ref']; + unprefixConfig?: { + domains: {[defaultLocale: string]: string}; + pathname: string; + }; }; -function BaseLink({href, locale, nodeRef, onClick, prefetch, ...rest}: Props) { +export default function BaseLink({ + href, + locale, + nodeRef, + onClick, + prefetch, + unprefixConfig, + ...rest +}: Props) { + const curLocale = useLocale(); + const isChangingLocale = locale !== curLocale; + const linkLocale = locale || curLocale; + + const host = useHost(); + const finalHref = + unprefixConfig && unprefixConfig.domains[linkLocale] === host + ? unprefixConfig.pathname + : href; + // The types aren't entirely correct here. Outside of Next.js // `useParams` can be called, but the return type is `null`. const pathname = usePathname() as ReturnType | null; - const curLocale = useLocale(); - const isChangingLocale = locale !== curLocale; - function onLinkClick(event: MouseEvent) { syncLocaleCookie(pathname, curLocale, locale); if (onClick) onClick(event); @@ -36,7 +55,7 @@ function BaseLink({href, locale, nodeRef, onClick, prefetch, ...rest}: Props) { return ( (); + + useEffect(() => { + setHost(window.location.host); + }, []); + + return host; +} diff --git a/packages/next-intl/src/navigation/shared/createSharedNavigationFns.tsx b/packages/next-intl/src/navigation/shared/createSharedNavigationFns.tsx index 66bb99202..a6661edec 100644 --- a/packages/next-intl/src/navigation/shared/createSharedNavigationFns.tsx +++ b/packages/next-intl/src/navigation/shared/createSharedNavigationFns.tsx @@ -9,7 +9,7 @@ import { RoutingConfigLocalizedNavigation, RoutingConfigSharedNavigation } from '../../routing/config'; -import {Locales, Pathnames} from '../../routing/types'; +import {DomainConfig, Locales, Pathnames} from '../../routing/types'; import {ParametersExceptFirst} from '../../shared/types'; import {isLocalizableHref} from '../../shared/utils'; import BaseLink from './BaseLink'; @@ -60,7 +60,8 @@ export default function createSharedNavigationFns< // that the user might get redirected again if the middleware detects that the // prefix is not needed. const forcePrefixSsr = - config.localePrefix.mode === 'as-needed' && 'domains' in config; + (config.localePrefix.mode === 'as-needed' && 'domains' in config) || + undefined; type LinkProps = Omit< ComponentProps, @@ -85,17 +86,18 @@ export default function createSharedNavigationFns< pathname = href; } - const curLocale = getLocale(); - // @ts-expect-error -- This is ok - const finalPathname = isLocalizableHref(href) + const isLocalizable = isLocalizableHref(href); + + const curLocale = getLocale(); + const finalPathname = isLocalizable ? getPathname( { locale: locale || curLocale, // @ts-expect-error -- This is ok href: pathnames == null ? pathname : {pathname, params} }, - locale != null || forcePrefixSsr + locale != null || forcePrefixSsr || undefined ) : pathname; @@ -107,6 +109,33 @@ export default function createSharedNavigationFns< pathname: finalPathname }} locale={locale} + // Provide the minimal relevant information to the client side in order + // to potentially remove the prefix in case of the `forcePrefixSsr` case + unprefixConfig={ + forcePrefixSsr && isLocalizable + ? { + domains: (config as any).domains.reduce( + ( + acc: Record, + domain: DomainConfig + ) => { + // @ts-expect-error -- This is ok + acc[domain.defaultLocale] = domain.domain; + return acc; + }, + {} + ), + pathname: getPathname( + { + locale: curLocale, + // @ts-expect-error -- This is ok + href: pathnames == null ? pathname : {pathname, params} + }, + false + ) + } + : undefined + } {...rest} /> ); diff --git a/packages/next-intl/src/navigation/shared/utils.tsx b/packages/next-intl/src/navigation/shared/utils.tsx index ae61bcda8..190054e20 100644 --- a/packages/next-intl/src/navigation/shared/utils.tsx +++ b/packages/next-intl/src/navigation/shared/utils.tsx @@ -228,8 +228,10 @@ export function applyPathnamePrefix( const {mode} = routing.localePrefix; let shouldPrefix; - if (isLocalizableHref(pathname)) { - if (force || mode === 'always') { + if (force !== undefined) { + shouldPrefix = force; + } else if (isLocalizableHref(pathname)) { + if (mode === 'always') { shouldPrefix = true; } else if (mode === 'as-needed') { let {defaultLocale} = routing; From 4301e9b5db148cb04e8d7ddb87a666563976f84e Mon Sep 17 00:00:00 2001 From: Jan Amann Date: Fri, 20 Sep 2024 15:15:33 +0200 Subject: [PATCH 29/62] fix lint --- packages/next-intl/src/navigation/shared/utils.tsx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/next-intl/src/navigation/shared/utils.tsx b/packages/next-intl/src/navigation/shared/utils.tsx index 190054e20..d2753a7db 100644 --- a/packages/next-intl/src/navigation/shared/utils.tsx +++ b/packages/next-intl/src/navigation/shared/utils.tsx @@ -21,10 +21,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); // For `Link` export type HrefOrUrlObjectWithParams = HrefOrHrefWithParamsImpl< From 49a75ef7755777473faf21f98b7c4f33836f53c3 Mon Sep 17 00:00:00 2001 From: Jan Amann Date: Fri, 20 Sep 2024 15:23:49 +0200 Subject: [PATCH 30/62] fix test script --- examples/example-app-router-playground/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/example-app-router-playground/package.json b/examples/example-app-router-playground/package.json index 7a970d61c..f2387b299 100644 --- a/examples/example-app-router-playground/package.json +++ b/examples/example-app-router-playground/package.json @@ -4,7 +4,7 @@ "scripts": { "dev": "next dev", "lint": "eslint src && tsc", - "test": "pnpm test:jest && node test.mjs", + "test": "pnpm test:jest && node runPlaywright.mjs", "test:playwright": "node runPlaywright.mjs", "test:jest": "jest", "build": "next build", From 63eafe62cd3d3c3016d50818dc4a5faa3927a05d Mon Sep 17 00:00:00 2001 From: Jan Amann Date: Wed, 25 Sep 2024 15:30:33 +0200 Subject: [PATCH 31/62] more tests --- .../src/navigation/createNavigation.test.tsx | 105 +++++++++++++++++- 1 file changed, 100 insertions(+), 5 deletions(-) diff --git a/packages/next-intl/src/navigation/createNavigation.test.tsx b/packages/next-intl/src/navigation/createNavigation.test.tsx index 32733ba2e..2888aaa93 100644 --- a/packages/next-intl/src/navigation/createNavigation.test.tsx +++ b/packages/next-intl/src/navigation/createNavigation.test.tsx @@ -686,11 +686,50 @@ describe.each([ }); }); - // describe("localePrefix: 'always', with `prefixes`", () => {}) - // describe("localePrefix: 'as-needed', no `locales`", () => {}) - // describe("localePrefix: 'as-needed', with `domains`", () => {}) - // describe("localePrefix: 'never', with `domains`", () => {}) - // describe("localePrefix: 'always', with `domains`", () => {}) + describe("localePrefix: 'always', with `domains`", () => { + const {Link, getPathname, permanentRedirect, redirect} = createNavigation({ + locales, + defaultLocale, + domains, + localePrefix: 'always' + }); + + describe('Link', () => { + it('renders a prefix during SSR', () => { + const markup = renderToString(About); + expect(markup).toContain('href="/en/about"'); + }); + + it('renders a prefix eventually on the client side', () => { + mockLocation({host: 'example.com'}); + render(About); + expect( + screen.getByRole('link', {name: 'About'}).getAttribute('href') + ).toBe('/en/about'); + }); + }); + + describe('getPathname', () => { + it('adds a prefix for the default locale without printing a warning', () => { + const originalConsoleError = globalThis.console.error; + globalThis.console.error = vi.fn(); + expect(getPathname({locale: 'en', href: '/about'})).toBe('/en/about'); + expect(globalThis.console.error).not.toHaveBeenCalled(); + globalThis.console.error = originalConsoleError; + }); + }); + + describe.each([ + ['redirect', redirect, nextRedirect], + ['permanentRedirect', permanentRedirect, nextPermanentRedirect] + ])('%s', (_, redirectFn, nextRedirectFn) => { + it('adds a prefix for the default locale', () => { + runInRender(() => redirectFn('/')); + expect(nextRedirectFn).toHaveBeenLastCalledWith('/en'); + }); + }); + }); + describe("localePrefix: 'as-needed', with `domains`", () => { const {Link, getPathname, permanentRedirect, redirect} = createNavigation({ locales, @@ -864,4 +903,60 @@ describe.each([ }); }); }); + + describe("localePrefix: 'never', with `domains`", () => { + const {Link, getPathname, permanentRedirect, redirect} = createNavigation({ + locales, + defaultLocale, + domains, + localePrefix: 'never' + }); + + describe('Link', () => { + it('renders no prefix during SSR', () => { + const markup = renderToString(About); + expect(markup).toContain('href="/about"'); + }); + + it('renders a prefix when linking to a secondary locale', () => { + const markup = renderToString( + + Über uns + + ); + expect(markup).toContain('href="/de/about"'); + expect(markup).toContain('hrefLang="de"'); + }); + + it('can link to a pathname on another domain', () => { + const markup = renderToString( + + Über uns + + ); + expect(markup).toContain('href="//example.de/about"'); + expect(markup).toContain('hrefLang="de"'); + }); + }); + + describe('getPathname', () => { + it('does not add a prefix for the default locale', () => { + const originalConsoleError = console.error; + console.error = vi.fn(); + expect(getPathname({locale: 'en', href: '/about'})).toBe('/about'); + expect(console.error).not.toHaveBeenCalled(); + console.error = originalConsoleError; + }); + }); + + describe.each([ + ['redirect', redirect, nextRedirect], + ['permanentRedirect', permanentRedirect, nextPermanentRedirect] + ])('%s', (_, redirectFn, nextRedirectFn) => { + it('adds no prefix for the default locale', () => { + runInRender(() => redirectFn('/')); + expect(nextRedirectFn).toHaveBeenLastCalledWith('/'); + }); + }); + }); }); From c9b7464d816ce593e400d5d99ca1edd6a5d07300 Mon Sep 17 00:00:00 2001 From: Jan Amann Date: Wed, 25 Sep 2024 15:35:34 +0200 Subject: [PATCH 32/62] more tests --- .../src/navigation/createNavigation.test.tsx | 61 +++++++++++++++++++ 1 file changed, 61 insertions(+) diff --git a/packages/next-intl/src/navigation/createNavigation.test.tsx b/packages/next-intl/src/navigation/createNavigation.test.tsx index 2888aaa93..b6790bf58 100644 --- a/packages/next-intl/src/navigation/createNavigation.test.tsx +++ b/packages/next-intl/src/navigation/createNavigation.test.tsx @@ -686,6 +686,67 @@ describe.each([ }); }); + describe('localePrefix: "always", with `prefixes`', () => { + const {Link, getPathname, permanentRedirect, redirect} = createNavigation({ + locales, + defaultLocale, + domains, + localePrefix: { + mode: 'always', + prefixes: { + en: '/us/en', + de: '/eu/de' + // (use /ja as-is) + } + } + }); + + describe('Link', () => { + it('renders a prefix during SSR', () => { + const markup = renderToString(About); + expect(markup).toContain('href="/us/en/about"'); + }); + + it('renders a prefix when currently on a secondary locale', () => { + mockCurrentLocale('de'); + render(About); + expect( + screen.getByRole('link', {name: 'About'}).getAttribute('href') + ).toBe('/eu/de/about'); + }); + }); + + describe('getPathname', () => { + it('adds a prefix for the default locale', () => { + expect(getPathname({locale: 'en', href: '/about'})).toBe( + '/us/en/about' + ); + }); + + it('adds a prefix for a secondary locale', () => { + expect(getPathname({locale: 'de', href: '/about'})).toBe( + '/eu/de/about' + ); + }); + }); + + describe.each([ + ['redirect', redirect, nextRedirect], + ['permanentRedirect', permanentRedirect, nextPermanentRedirect] + ])('%s', (_, redirectFn, nextRedirectFn) => { + it('adds a prefix for the default locale', () => { + runInRender(() => redirectFn('/')); + expect(nextRedirectFn).toHaveBeenLastCalledWith('/us/en'); + }); + + it('adds a prefix for a secondary locale', () => { + mockCurrentLocale('de'); + runInRender(() => redirectFn('/about')); + expect(nextRedirectFn).toHaveBeenLastCalledWith('/eu/de/about'); + }); + }); + }); + describe("localePrefix: 'always', with `domains`", () => { const {Link, getPathname, permanentRedirect, redirect} = createNavigation({ locales, From dd053e88b15f68ec5ad37674b6822c190677c791 Mon Sep 17 00:00:00 2001 From: Jan Amann Date: Wed, 25 Sep 2024 16:59:25 +0200 Subject: [PATCH 33/62] first support for domains with useRouter --- .../src/navigation/createNavigation.test.tsx | 26 ++---- .../react-client/createNavigation.test.tsx | 92 ++++++++++++++++--- .../react-client/createNavigation.tsx | 3 +- .../next-intl/src/navigation/shared/utils.tsx | 14 +-- 4 files changed, 92 insertions(+), 43 deletions(-) diff --git a/packages/next-intl/src/navigation/createNavigation.test.tsx b/packages/next-intl/src/navigation/createNavigation.test.tsx index b6790bf58..38aa05794 100644 --- a/packages/next-intl/src/navigation/createNavigation.test.tsx +++ b/packages/next-intl/src/navigation/createNavigation.test.tsx @@ -772,11 +772,10 @@ describe.each([ describe('getPathname', () => { it('adds a prefix for the default locale without printing a warning', () => { - const originalConsoleError = globalThis.console.error; - globalThis.console.error = vi.fn(); + const consoleSpy = vi.spyOn(console, 'error'); expect(getPathname({locale: 'en', href: '/about'})).toBe('/en/about'); - expect(globalThis.console.error).not.toHaveBeenCalled(); - globalThis.console.error = originalConsoleError; + expect(consoleSpy).not.toHaveBeenCalled(); + consoleSpy.mockRestore(); }); }); @@ -855,23 +854,10 @@ describe.each([ }); it('prints a warning when no domain is provided', () => { - const originalConsoleError = globalThis.console.error; - globalThis.console.error = vi.fn(); + const consoleSpy = vi.spyOn(console, 'error'); getPathname({locale: 'de', href: '/about'}); - expect(globalThis.console.error).toHaveBeenCalledWith( - "You're using a routing configuration with `localePrefix: 'as-needed'` in combination with `domains`. In order to compute a correct pathname, you need to provide a `domain` parameter." - ); - globalThis.console.error = originalConsoleError; - }); - - it('prints a warning when an unknown domain is provided', () => { - const originalConsoleError = globalThis.console.error; - globalThis.console.error = vi.fn(); - getPathname({locale: 'de', href: '/about', domain: 'example.org'}); - expect(globalThis.console.error).toHaveBeenCalledWith( - 'Domain "example.org" not found in the routing configuration. Available domains: example.com, example.de' - ); - globalThis.console.error = originalConsoleError; + expect(consoleSpy).toHaveBeenCalled(); + consoleSpy.mockRestore(); }); }); diff --git a/packages/next-intl/src/navigation/react-client/createNavigation.test.tsx b/packages/next-intl/src/navigation/react-client/createNavigation.test.tsx index 376e4a931..b952222a7 100644 --- a/packages/next-intl/src/navigation/react-client/createNavigation.test.tsx +++ b/packages/next-intl/src/navigation/react-client/createNavigation.test.tsx @@ -8,7 +8,7 @@ import { import React from 'react'; import {beforeEach, describe, expect, it, vi} from 'vitest'; import {NextIntlClientProvider} from '../../react-client'; -import {Pathnames} from '../../routing'; +import {DomainsConfig, Pathnames} from '../../routing'; import createNavigation from './createNavigation'; vi.mock('next/navigation'); @@ -19,17 +19,19 @@ function mockCurrentLocale(locale: string) { })); } -function mockLocation(pathname: string, basePath = '') { - vi.mocked(useNextPathname).mockReturnValue(pathname); - +function mockLocation(location: Partial) { delete (global.window as any).location; global.window ??= Object.create(window); - (global.window as any).location = {pathname: basePath + pathname}; + (global.window as any).location = location; + + if (location.pathname) { + vi.mocked(useNextPathname).mockReturnValue(location.pathname); + } } beforeEach(() => { mockCurrentLocale('en'); - mockLocation('/en'); + mockLocation({host: 'localhost:3000', pathname: '/en'}); const router = { push: vi.fn(), @@ -45,6 +47,18 @@ beforeEach(() => { const locales = ['en', 'de', 'ja'] as const; const defaultLocale = 'en' as const; +const domains: DomainsConfig = [ + { + defaultLocale: 'en', + domain: 'example.com' + }, + { + defaultLocale: 'de', + domain: 'example.de', + locales: ['de', 'en'] + } +]; + const pathnames = { '/': '/', '/about': { @@ -194,9 +208,11 @@ describe("localePrefix: 'always'", () => { }); describe('usePathname', () => { + const renderPathname = getRenderPathname(usePathname); + it('returns the correct pathname for the default locale', () => { mockCurrentLocale('en'); - mockLocation('/en/about'); + mockLocation({pathname: '/en/about'}); renderPathname(); screen.getByText('/about'); @@ -204,7 +220,7 @@ describe("localePrefix: 'always'", () => { it('returns the correct pathname for a secondary locale', () => { mockCurrentLocale('de'); - mockLocation('/de/about'); + mockLocation({pathname: '/de/about'}); renderPathname(); screen.getByText('/about'); @@ -220,7 +236,7 @@ describe("localePrefix: 'always', with `basePath`", () => { }); beforeEach(() => { - mockLocation('/en', '/base/path'); + mockLocation({pathname: '/base/path/en'}); }); describe('useRouter', () => { @@ -279,7 +295,7 @@ describe("localePrefix: 'always', custom `prefixes`", () => { describe('usePathname', () => { it('returns the correct pathname for a custom locale prefix', () => { mockCurrentLocale('en'); - mockLocation('/uk/about'); + mockLocation({pathname: '/uk/about'}); renderPathname(); screen.getByText('/about'); }); @@ -338,7 +354,7 @@ describe("localePrefix: 'as-needed'", () => { describe('usePathname', () => { it('returns the correct pathname for the default locale', () => { mockCurrentLocale('en'); - mockLocation('/about'); + mockLocation({pathname: '/about'}); renderPathname(); screen.getByText('/about'); @@ -346,7 +362,7 @@ describe("localePrefix: 'as-needed'", () => { it('returns the correct pathname for a secondary locale', () => { mockCurrentLocale('de'); - mockLocation('/de/about'); + mockLocation({pathname: '/de/about'}); renderPathname(); screen.getByText('/about'); @@ -354,6 +370,52 @@ describe("localePrefix: 'as-needed'", () => { }); }); +describe("localePrefix: 'as-needed', with `domains`", () => { + const {usePathname, useRouter} = createNavigation({ + locales, + defaultLocale, + domains, + localePrefix: 'as-needed' + }); + + describe('useRouter', () => { + const invokeRouter = getInvokeRouter(useRouter); + + describe.each(['push', 'replace'] as const)('`%s`', (method) => { + it('does not prefix the default locale when on a domain with a matching defaultLocale', () => { + mockCurrentLocale('en'); + mockLocation({pathname: '/about', host: 'example.com'}); + invokeRouter((router) => router[method]('/about')); + expect(useNextRouter()[method]).toHaveBeenCalledWith('/about'); + }); + + it('does not prefix the default locale when on a domain with a different defaultLocale', () => { + mockCurrentLocale('de'); + mockLocation({pathname: '/about', host: 'example.de'}); + invokeRouter((router) => router[method]('/about')); + expect(useNextRouter()[method]).toHaveBeenCalledWith('/about'); + }); + + it('does not prefix the default locale when on an unknown domain', () => { + const consoleSpy = vi.spyOn(console, 'error'); + mockCurrentLocale('en'); + mockLocation({pathname: '/about', host: 'localhost:3000'}); + invokeRouter((router) => router[method]('/about')); + expect(useNextRouter()[method]).toHaveBeenCalledWith('/about'); + expect(consoleSpy).not.toHaveBeenCalled(); + consoleSpy.mockRestore(); + }); + + it('prefixes the default locale when on a domain with a different defaultLocale', () => { + mockCurrentLocale('de'); + mockLocation({pathname: '/about', host: 'example.de'}); + invokeRouter((router) => router[method]('/about', {locale: 'en'})); + expect(useNextRouter()[method]).toHaveBeenCalledWith('/en/about'); + }); + }); + }); +}); + describe("localePrefix: 'never'", () => { const {Link, usePathname, useRouter} = createNavigation({ locales, @@ -449,7 +511,7 @@ describe("localePrefix: 'never'", () => { describe('usePathname', () => { it('returns the correct pathname for the default locale', () => { mockCurrentLocale('en'); - mockLocation('/about'); + mockLocation({pathname: '/about'}); renderPathname(); screen.getByText('/about'); @@ -457,7 +519,7 @@ describe("localePrefix: 'never'", () => { it('returns the correct pathname for a secondary locale', () => { mockCurrentLocale('de'); - mockLocation('/about'); + mockLocation({pathname: '/about'}); renderPathname(); screen.getByText('/about'); @@ -473,7 +535,7 @@ describe("localePrefix: 'never', with `basePath`", () => { }); beforeEach(() => { - mockLocation('/en', '/base/path'); + mockLocation({pathname: '/base/path/en'}); }); describe('useRouter', () => { diff --git a/packages/next-intl/src/navigation/react-client/createNavigation.tsx b/packages/next-intl/src/navigation/react-client/createNavigation.tsx index 9041cea28..abea26498 100644 --- a/packages/next-intl/src/navigation/react-client/createNavigation.tsx +++ b/packages/next-intl/src/navigation/react-client/createNavigation.tsx @@ -88,7 +88,8 @@ export default function createNavigation< const pathname = getPathname({ // @ts-expect-error -- This is fine href, - locale: nextLocale || curLocale + locale: nextLocale || curLocale, + domain: window.location.host }); const args: [href: string, options?: Options] = [pathname]; diff --git a/packages/next-intl/src/navigation/shared/utils.tsx b/packages/next-intl/src/navigation/shared/utils.tsx index d2753a7db..30284b9a7 100644 --- a/packages/next-intl/src/navigation/shared/utils.tsx +++ b/packages/next-intl/src/navigation/shared/utils.tsx @@ -245,16 +245,16 @@ export function applyPathnamePrefix( } else if (process.env.NODE_ENV !== 'production') { if (!domain) { console.error( - "You're using a routing configuration with `localePrefix: 'as-needed'` in combination with `domains`. In order to compute a correct pathname, you need to provide a `domain` parameter." - // TODO: Link to docs. q: which apis are affected? + "You're using a routing configuration with `localePrefix: 'as-needed'` in combination with `domains`. In order to compute a correct pathname, you need to provide a `domain` parameter (e.g. based on the `x-forwarded-host` header). If this is not possible (e.g. when relying on static rendering), you can alternatively redirect on the client side via `useRouter`." + + // TODO: Link to docs. q: which apis are affected? consider in error message // solution for user: provide domain manually, read from host header ); } else { - console.error( - `Domain "${domain}" not found in the routing configuration. Available domains: ${routing.domains - .map((cur) => cur.domain) - .join(', ')}` - ); + // If a domain was provided, but it wasn't found in the routing + // configuration, this can be an indicator that the user is on + // localhost. In this case, we can simply use the domain-agnostic + // default locale. } } } From bdd0c840cdd2727ca58e9a0460d62128a8a022fa Mon Sep 17 00:00:00 2001 From: Jan Amann Date: Wed, 25 Sep 2024 17:04:37 +0200 Subject: [PATCH 34/62] tests for usePathname --- .../react-client/createNavigation.test.tsx | 33 ++++++++++++++++++- 1 file changed, 32 insertions(+), 1 deletion(-) diff --git a/packages/next-intl/src/navigation/react-client/createNavigation.test.tsx b/packages/next-intl/src/navigation/react-client/createNavigation.test.tsx index b952222a7..38e49ff7a 100644 --- a/packages/next-intl/src/navigation/react-client/createNavigation.test.tsx +++ b/packages/next-intl/src/navigation/react-client/createNavigation.test.tsx @@ -105,7 +105,6 @@ describe("localePrefix: 'always'", () => { defaultLocale, localePrefix: 'always' }); - const renderPathname = getRenderPathname(usePathname); describe('Link', () => { describe('usage outside of Next.js', () => { @@ -414,6 +413,38 @@ describe("localePrefix: 'as-needed', with `domains`", () => { }); }); }); + + const renderPathname = getRenderPathname(usePathname); + + describe('usePathname', () => { + it('returns the correct pathname for the default locale', () => { + mockCurrentLocale('en'); + mockLocation({pathname: '/about', host: 'example.com'}); + renderPathname(); + screen.getByText('/about'); + }); + + it('returns the correct pathname for a secondary locale', () => { + mockCurrentLocale('de'); + mockLocation({pathname: '/de/about', host: 'example.com'}); + renderPathname(); + screen.getByText('/about'); + }); + + it('returns the correct pathname for the default locale on a domain with a different defaultLocale', () => { + mockCurrentLocale('de'); + mockLocation({pathname: '/about', host: 'example.de'}); + renderPathname(); + screen.getByText('/about'); + }); + + it('returns the correct pathname for a secondary locale on a domain with a different defaultLocale', () => { + mockCurrentLocale('en'); + mockLocation({pathname: '/en/about', host: 'example.de'}); + renderPathname(); + screen.getByText('/about'); + }); + }); }); describe("localePrefix: 'never'", () => { From 2bc39405118955908ba94285763869bcb8973544 Mon Sep 17 00:00:00 2001 From: Jan Amann Date: Wed, 25 Sep 2024 17:14:14 +0200 Subject: [PATCH 35/62] improve comments --- .../src/navigation/shared/createSharedNavigationFns.tsx | 2 +- packages/next-intl/src/navigation/shared/utils.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/next-intl/src/navigation/shared/createSharedNavigationFns.tsx b/packages/next-intl/src/navigation/shared/createSharedNavigationFns.tsx index a6661edec..4dacb4dc2 100644 --- a/packages/next-intl/src/navigation/shared/createSharedNavigationFns.tsx +++ b/packages/next-intl/src/navigation/shared/createSharedNavigationFns.tsx @@ -152,7 +152,7 @@ export default function createSharedNavigationFns< href: [AppPathnames] extends [never] ? string | {pathname: string; query?: QueryParams} : HrefOrHrefWithParams; - /** In case you're using `localePrefix: 'as-necessary'` in combination with `domains`, the `defaultLocale` can differ by domain and therefore the locales that need to be prefixed can differ as well. For this particular case, this parameter should be provided in order to compute the correct pathname. Note that the actual domain is not part of the result, but only the pathname is returned. */ + /** In case you're using `localePrefix: 'as-needed'` in combination with `domains`, the `defaultLocale` can differ by domain and therefore the locales that need to be prefixed can differ as well. For this particular case, this parameter should be provided in order to compute the correct pathname. Note that the actual domain is not part of the result, but only the pathname is returned. */ domain?: string; }, /** @private */ diff --git a/packages/next-intl/src/navigation/shared/utils.tsx b/packages/next-intl/src/navigation/shared/utils.tsx index 30284b9a7..600d92c33 100644 --- a/packages/next-intl/src/navigation/shared/utils.tsx +++ b/packages/next-intl/src/navigation/shared/utils.tsx @@ -247,7 +247,7 @@ export function applyPathnamePrefix( console.error( "You're using a routing configuration with `localePrefix: 'as-needed'` in combination with `domains`. In order to compute a correct pathname, you need to provide a `domain` parameter (e.g. based on the `x-forwarded-host` header). If this is not possible (e.g. when relying on static rendering), you can alternatively redirect on the client side via `useRouter`." - // TODO: Link to docs. q: which apis are affected? consider in error message + // TODO: Link to docs. q: which apis are affected? consider in error message. currently we assume only redirect, but getPathname is affected too (with no alternative). a proper docs page would go a long way probably. // solution for user: provide domain manually, read from host header ); } else { From 5aa506b6d737cc52e749a9462ab260f4bbe76211 Mon Sep 17 00:00:00 2001 From: Jan Amann Date: Wed, 25 Sep 2024 17:44:19 +0200 Subject: [PATCH 36/62] more tests --- .../src/navigation/createNavigation.test.tsx | 15 ++++++++++++++- .../shared/createSharedNavigationFns.tsx | 2 -- 2 files changed, 14 insertions(+), 3 deletions(-) diff --git a/packages/next-intl/src/navigation/createNavigation.test.tsx b/packages/next-intl/src/navigation/createNavigation.test.tsx index 38aa05794..815091d76 100644 --- a/packages/next-intl/src/navigation/createNavigation.test.tsx +++ b/packages/next-intl/src/navigation/createNavigation.test.tsx @@ -190,6 +190,19 @@ describe.each([ // Still works expect(markup).toContain('href="/zh/about"'); }); + + it('does not allow to receive params', () => { + ; + }); }); describe('getPathname', () => { @@ -468,7 +481,7 @@ describe.each([ ; // @ts-expect-error -- Unknown pathname ; - // @ts-expect-error -- Missing params + // @ts-expect-error -- Missing params (this error is important when switching from shared pathnames to localized pathnames) ; }); }); diff --git a/packages/next-intl/src/navigation/shared/createSharedNavigationFns.tsx b/packages/next-intl/src/navigation/shared/createSharedNavigationFns.tsx index 4dacb4dc2..16f55df9b 100644 --- a/packages/next-intl/src/navigation/shared/createSharedNavigationFns.tsx +++ b/packages/next-intl/src/navigation/shared/createSharedNavigationFns.tsx @@ -141,7 +141,6 @@ export default function createSharedNavigationFns< ); } - // New: getPathname is available for shared pathnames function getPathname( { domain, @@ -157,7 +156,6 @@ export default function createSharedNavigationFns< }, /** @private */ _forcePrefix?: boolean - // TODO: Should we somehow ensure this doesn't get emitted to the types? ) { let pathname: string; if (pathnames == null) { From 9274cda7ac7ea8586e04cb5652d0e4817c796b02 Mon Sep 17 00:00:00 2001 From: Jan Amann Date: Thu, 26 Sep 2024 11:57:31 +0200 Subject: [PATCH 37/62] Strict typing for `domain` in `getPathname` --- packages/next-intl/.eslintrc.js | 3 +- .../getAlternateLinksHeaderValue.tsx | 21 ++++- .../src/middleware/middleware.test.tsx | 4 +- .../next-intl/src/middleware/middleware.tsx | 31 +++++-- .../src/middleware/resolveLocale.tsx | 55 ++++++++---- packages/next-intl/src/middleware/utils.tsx | 24 ++++-- .../src/navigation/createNavigation.test.tsx | 10 ++- .../react-client/ClientLink.test.tsx | 2 +- .../navigation/react-client/ClientLink.tsx | 29 +++++-- ...reateLocalizedPathnamesNavigation.test.tsx | 1 - .../createLocalizedPathnamesNavigation.tsx | 20 ++++- .../react-client/createNavigation.tsx | 28 ++++-- .../createSharedPathnamesNavigation.tsx | 20 +++-- .../react-client/useBasePathname.tsx | 13 ++- .../navigation/react-client/useBaseRouter.tsx | 13 ++- .../navigation/react-server/ServerLink.tsx | 22 +++-- .../createLocalizedPathnamesNavigation.tsx | 20 ++++- .../react-server/createNavigation.tsx | 26 +++++- .../createSharedPathnamesNavigation.tsx | 26 ++++-- .../shared/createSharedNavigationFns.tsx | 85 ++++++++++++------- .../src/navigation/shared/redirects.tsx | 13 ++- .../next-intl/src/navigation/shared/utils.tsx | 52 ++++++++++-- packages/next-intl/src/react-client/index.tsx | 1 - .../src/react-server/useTranslations.test.tsx | 1 + packages/next-intl/src/routing/config.tsx | 76 ++++++++++++----- .../next-intl/src/routing/defineRouting.tsx | 15 +++- packages/next-intl/src/routing/types.tsx | 33 ++++--- packages/next-intl/src/shared/utils.tsx | 13 ++- 28 files changed, 484 insertions(+), 173 deletions(-) diff --git a/packages/next-intl/.eslintrc.js b/packages/next-intl/.eslintrc.js index 46c1bde5e..bd87e835f 100644 --- a/packages/next-intl/.eslintrc.js +++ b/packages/next-intl/.eslintrc.js @@ -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: [ { diff --git a/packages/next-intl/src/middleware/getAlternateLinksHeaderValue.tsx b/packages/next-intl/src/middleware/getAlternateLinksHeaderValue.tsx index e09f4d64e..2c14439ad 100644 --- a/packages/next-intl/src/middleware/getAlternateLinksHeaderValue.tsx +++ b/packages/next-intl/src/middleware/getAlternateLinksHeaderValue.tsx @@ -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, @@ -16,14 +21,24 @@ import { */ export default function getAlternateLinksHeaderValue< AppLocales extends Locales, - AppPathnames extends Pathnames = never + AppLocalePrefixMode extends LocalePrefixMode, + AppPathnames extends Pathnames | undefined, + AppDomains extends DomainsConfig | undefined >({ localizedPathnames, request, resolvedLocale, routing }: { - routing: ResolvedRoutingConfig; + routing: Omit< + ResolvedRoutingConfig< + AppLocales, + AppLocalePrefixMode, + AppPathnames, + AppDomains + >, + 'pathnames' + >; request: NextRequest; resolvedLocale: AppLocales[number]; localizedPathnames?: Pathnames[string]; diff --git a/packages/next-intl/src/middleware/middleware.test.tsx b/packages/next-intl/src/middleware/middleware.test.tsx index 5417e5b37..1e466527e 100644 --- a/packages/next-intl/src/middleware/middleware.test.tsx +++ b/packages/next-intl/src/middleware/middleware.test.tsx @@ -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: { @@ -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: { diff --git a/packages/next-intl/src/middleware/middleware.tsx b/packages/next-intl/src/middleware/middleware.tsx index 20fea6f99..0a180d7e7 100644 --- a/packages/next-intl/src/middleware/middleware.tsx +++ b/packages/next-intl/src/middleware/middleware.tsx @@ -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, @@ -26,9 +31,16 @@ import { export default function createMiddleware< AppLocales extends Locales, - AppPathnames extends Pathnames = never + AppLocalePrefixMode extends LocalePrefixMode = 'always', + AppPathnames extends Pathnames = never, + AppDomains extends DomainsConfig = never >( - routing: RoutingConfig & + routing: RoutingConfig< + AppLocales, + AppLocalePrefixMode, + AppPathnames, + AppDomains + > & // Convenience if `routing` is generated dynamically (i.e. without `defineRouting`) MiddlewareOptions, options?: MiddlewareOptions @@ -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 @@ -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 diff --git a/packages/next-intl/src/middleware/resolveLocale.tsx b/packages/next-intl/src/middleware/resolveLocale.tsx index 92043a659..a9330edc2 100644 --- a/packages/next-intl/src/middleware/resolveLocale.tsx +++ b/packages/next-intl/src/middleware/resolveLocale.tsx @@ -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'; @@ -74,13 +75,23 @@ function getLocaleFromCookie( function resolveLocaleFromPrefix< AppLocales extends Locales, - AppPathnames extends Pathnames = never + AppLocalePrefixMode extends LocalePrefixMode, + AppPathnames extends Pathnames | undefined, + AppDomains extends DomainsConfig | undefined >( { defaultLocale, localePrefix, locales - }: ResolvedRoutingConfig, + }: Omit< + ResolvedRoutingConfig< + AppLocales, + AppLocalePrefixMode, + AppPathnames, + AppDomains + >, + 'pathnames' + >, {localeDetection}: ResolvedMiddlewareOptions, requestHeaders: Headers, requestCookies: RequestCookies, @@ -113,10 +124,19 @@ function resolveLocaleFromPrefix< function resolveLocaleFromDomain< AppLocales extends Locales, - AppPathnames extends Pathnames = never + AppLocalePrefixMode extends LocalePrefixMode, + AppPathnames extends Pathnames | undefined, + AppDomains extends DomainsConfig | undefined >( - routing: Omit, 'domains'> & - Required, 'domains'>>, + routing: Omit< + ResolvedRoutingConfig< + AppLocales, + AppLocalePrefixMode, + AppPathnames, + AppDomains + >, + 'pathnames' + >, options: ResolvedMiddlewareOptions, requestHeaders: Headers, requestCookies: RequestCookies, @@ -191,24 +211,27 @@ function resolveLocaleFromDomain< export default function resolveLocale< AppLocales extends Locales, - AppPathnames extends Pathnames = never + AppLocalePrefixMode extends LocalePrefixMode, + AppPathnames extends Pathnames | undefined, + AppDomains extends DomainsConfig | undefined >( - routing: ResolvedRoutingConfig, + routing: Omit< + ResolvedRoutingConfig< + AppLocales, + AppLocalePrefixMode, + AppPathnames, + AppDomains + >, + 'pathnames' + >, options: ResolvedMiddlewareOptions, requestHeaders: Headers, requestCookies: RequestCookies, pathname: string ): {locale: AppLocales[number]; domain?: DomainConfig} { if (routing.domains) { - const routingWithDomains = routing as Omit< - ResolvedRoutingConfig, - 'domains' - > & - Required< - Pick, 'domains'> - >; return resolveLocaleFromDomain( - routingWithDomains, + routing, options, requestHeaders, requestCookies, diff --git a/packages/next-intl/src/middleware/utils.tsx b/packages/next-intl/src/middleware/utils.tsx index 6b7da0234..78c7fe9c9 100644 --- a/packages/next-intl/src/middleware/utils.tsx +++ b/packages/next-intl/src/middleware/utils.tsx @@ -3,7 +3,8 @@ import { LocalePrefixConfigVerbose, DomainConfig, Pathnames, - DomainsConfig + DomainsConfig, + LocalePrefixMode } from '../routing/types'; import { getLocalePrefix, @@ -92,10 +93,13 @@ export function formatTemplatePathname( /** * Removes potential prefixes from the pathname. */ -export function getNormalizedPathname( +export function getNormalizedPathname< + AppLocales extends Locales, + AppLocalePrefixMode extends LocalePrefixMode +>( pathname: string, locales: AppLocales, - localePrefix: LocalePrefixConfigVerbose + localePrefix: LocalePrefixConfigVerbose ) { // Add trailing slash for consistent handling // both for the root as well as nested paths @@ -127,9 +131,12 @@ export function findCaseInsensitiveString( return strings.find((cur) => cur.toLowerCase() === candidate.toLowerCase()); } -export function getLocalePrefixes( +export function getLocalePrefixes< + AppLocales extends Locales, + AppLocalePrefixMode extends LocalePrefixMode +>( locales: AppLocales, - localePrefix: LocalePrefixConfigVerbose, + localePrefix: LocalePrefixConfigVerbose, sort = true ): Array<[AppLocales[number], string]> { const prefixes = locales.map((locale) => [ @@ -145,10 +152,13 @@ export function getLocalePrefixes( return prefixes as Array<[AppLocales[number], string]>; } -export function getPathnameMatch( +export function getPathnameMatch< + AppLocales extends Locales, + AppLocalePrefixMode extends LocalePrefixMode +>( pathname: string, locales: AppLocales, - localePrefix: LocalePrefixConfigVerbose + localePrefix: LocalePrefixConfigVerbose ): | { locale: AppLocales[number]; diff --git a/packages/next-intl/src/navigation/createNavigation.test.tsx b/packages/next-intl/src/navigation/createNavigation.test.tsx index 815091d76..eaa799e61 100644 --- a/packages/next-intl/src/navigation/createNavigation.test.tsx +++ b/packages/next-intl/src/navigation/createNavigation.test.tsx @@ -54,7 +54,7 @@ beforeEach(() => { const locales = ['en', 'de', 'ja'] as const; const defaultLocale = 'en' as const; -const domains: DomainsConfig = [ +const domains = [ { defaultLocale: 'en', domain: 'example.com' @@ -64,7 +64,7 @@ const domains: DomainsConfig = [ domain: 'example.de', locales: ['de', 'en'] } -]; +] satisfies DomainsConfig; const pathnames = { '/': '/', @@ -277,6 +277,11 @@ describe.each([ getPathname({locale: 'en', href: 'https://example.com/about'}) ).toBe('https://example.com/about'); }); + + it('does not allow to pass a domain', () => { + // @ts-expect-error -- Domain is not supported + getPathname({locale: 'en', href: '/', domain: 'example.com'}); + }); }); describe.each([ @@ -868,6 +873,7 @@ describe.each([ it('prints a warning when no domain is provided', () => { const consoleSpy = vi.spyOn(console, 'error'); + // @ts-expect-error -- Domain is not provided getPathname({locale: 'de', href: '/about'}); expect(consoleSpy).toHaveBeenCalled(); consoleSpy.mockRestore(); diff --git a/packages/next-intl/src/navigation/react-client/ClientLink.test.tsx b/packages/next-intl/src/navigation/react-client/ClientLink.test.tsx index 19d14172b..185985e6a 100644 --- a/packages/next-intl/src/navigation/react-client/ClientLink.test.tsx +++ b/packages/next-intl/src/navigation/react-client/ClientLink.test.tsx @@ -25,7 +25,7 @@ const MockClientLink = forwardRef( localePrefix = {mode: 'always'}, ...rest }: Omit, 'localePrefix'> & { - localePrefix?: LocalePrefixConfigVerbose; + localePrefix?: LocalePrefixConfigVerbose; }, ref ) => ( diff --git a/packages/next-intl/src/navigation/react-client/ClientLink.tsx b/packages/next-intl/src/navigation/react-client/ClientLink.tsx index 634404a7a..6d047747d 100644 --- a/packages/next-intl/src/navigation/react-client/ClientLink.tsx +++ b/packages/next-intl/src/navigation/react-client/ClientLink.tsx @@ -1,20 +1,30 @@ import React, {ComponentProps, ReactElement, forwardRef} from 'react'; import useLocale from '../../react-client/useLocale'; -import {Locales, LocalePrefixConfigVerbose} from '../../routing/types'; +import { + Locales, + LocalePrefixConfigVerbose, + LocalePrefixMode +} from '../../routing/types'; import {getLocalePrefix} from '../../shared/utils'; import LegacyBaseLink from '../shared/LegacyBaseLink'; -type Props = Omit< +type Props< + AppLocales extends Locales, + AppLocalePrefixMode extends LocalePrefixMode +> = Omit< ComponentProps, 'locale' | 'prefix' | 'localePrefixMode' > & { locale?: AppLocales[number]; - localePrefix: LocalePrefixConfigVerbose; + localePrefix: LocalePrefixConfigVerbose; }; -function ClientLink( - {locale, localePrefix, ...rest}: Props, - ref: Props['ref'] +function ClientLink< + AppLocales extends Locales, + AppLocalePrefixMode extends LocalePrefixMode +>( + {locale, localePrefix, ...rest}: Props, + ref: Props['ref'] ) { const defaultLocale = useLocale(); const finalLocale = locale || defaultLocale; @@ -52,9 +62,12 @@ function ClientLink( * page to be overwritten before the user even decides to change the locale. */ const ClientLinkWithRef = forwardRef(ClientLink) as < - AppLocales extends Locales + AppLocales extends Locales, + AppLocalePrefixMode extends LocalePrefixMode >( - props: Props & {ref?: Props['ref']} + props: Props & { + ref?: Props['ref']; + } ) => ReactElement; (ClientLinkWithRef as any).displayName = 'ClientLink'; export default ClientLinkWithRef; diff --git a/packages/next-intl/src/navigation/react-client/createLocalizedPathnamesNavigation.test.tsx b/packages/next-intl/src/navigation/react-client/createLocalizedPathnamesNavigation.test.tsx index d8e3f8bf8..e711d0f63 100644 --- a/packages/next-intl/src/navigation/react-client/createLocalizedPathnamesNavigation.test.tsx +++ b/packages/next-intl/src/navigation/react-client/createLocalizedPathnamesNavigation.test.tsx @@ -433,7 +433,6 @@ describe("localePrefix: 'as-needed'", () => { useRouter: useRouterWithUnknown } = createLocalizedPathnamesNavigation({ locales, - // eslint-disable-next-line @typescript-eslint/ban-types pathnames: pathnames as typeof pathnames & Record }); Unknown; diff --git a/packages/next-intl/src/navigation/react-client/createLocalizedPathnamesNavigation.tsx b/packages/next-intl/src/navigation/react-client/createLocalizedPathnamesNavigation.tsx index fb7ec7284..61a8c5671 100644 --- a/packages/next-intl/src/navigation/react-client/createLocalizedPathnamesNavigation.tsx +++ b/packages/next-intl/src/navigation/react-client/createLocalizedPathnamesNavigation.tsx @@ -4,7 +4,12 @@ import { receiveRoutingConfig, RoutingConfigLocalizedNavigation } from '../../routing/config'; -import {Locales, Pathnames} from '../../routing/types'; +import { + DomainsConfig, + LocalePrefixMode, + Locales, + Pathnames +} from '../../routing/types'; import {ParametersExceptFirst} from '../../shared/types'; import { compileLocalizedPathname, @@ -20,8 +25,17 @@ import useBaseRouter from './useBaseRouter'; export default function createLocalizedPathnamesNavigation< AppLocales extends Locales, - AppPathnames extends Pathnames ->(routing: RoutingConfigLocalizedNavigation) { + AppLocalePrefixMode extends LocalePrefixMode = 'always', + AppPathnames extends Pathnames = never, + AppDomains extends DomainsConfig = never +>( + routing: RoutingConfigLocalizedNavigation< + AppLocales, + AppLocalePrefixMode, + AppPathnames, + AppDomains + > +) { const config = receiveRoutingConfig(routing); function useTypedLocale(): AppLocales[number] { diff --git a/packages/next-intl/src/navigation/react-client/createNavigation.tsx b/packages/next-intl/src/navigation/react-client/createNavigation.tsx index abea26498..947209950 100644 --- a/packages/next-intl/src/navigation/react-client/createNavigation.tsx +++ b/packages/next-intl/src/navigation/react-client/createNavigation.tsx @@ -8,7 +8,12 @@ import { RoutingConfigLocalizedNavigation, RoutingConfigSharedNavigation } from '../../routing/config'; -import {Locales, Pathnames} from '../../routing/types'; +import { + DomainsConfig, + LocalePrefixMode, + Locales, + Pathnames +} from '../../routing/types'; import createSharedNavigationFns from '../shared/createSharedNavigationFns'; import syncLocaleCookie from '../shared/syncLocaleCookie'; import {getRoute} from '../shared/utils'; @@ -16,11 +21,24 @@ import useBasePathname from './useBasePathname'; export default function createNavigation< const AppLocales extends Locales, - const AppPathnames extends Pathnames = never + const AppLocalePrefixMode extends LocalePrefixMode = 'always', + const AppPathnames extends Pathnames = never, + const AppDomains extends DomainsConfig = never >( routing?: [AppPathnames] extends [never] - ? RoutingConfigSharedNavigation | undefined - : RoutingConfigLocalizedNavigation + ? + | RoutingConfigSharedNavigation< + AppLocales, + AppLocalePrefixMode, + AppDomains + > + | undefined + : RoutingConfigLocalizedNavigation< + AppLocales, + AppLocalePrefixMode, + AppPathnames, + AppDomains + > ) { type Locale = AppLocales extends never ? string : AppLocales[number]; @@ -85,8 +103,8 @@ export default function createNavigation< ): void { const {locale: nextLocale, ...rest} = options || {}; + // @ts-expect-error -- We're passing a domain here just in case const pathname = getPathname({ - // @ts-expect-error -- This is fine href, locale: nextLocale || curLocale, domain: window.location.host diff --git a/packages/next-intl/src/navigation/react-client/createSharedPathnamesNavigation.tsx b/packages/next-intl/src/navigation/react-client/createSharedPathnamesNavigation.tsx index cdc10cb20..cdc199425 100644 --- a/packages/next-intl/src/navigation/react-client/createSharedPathnamesNavigation.tsx +++ b/packages/next-intl/src/navigation/react-client/createSharedPathnamesNavigation.tsx @@ -3,7 +3,7 @@ import { receiveLocalePrefixConfig, RoutingConfigSharedNavigation } from '../../routing/config'; -import {Locales} from '../../routing/types'; +import {DomainsConfig, LocalePrefixMode, Locales} from '../../routing/types'; import {ParametersExceptFirst} from '../../shared/types'; import ClientLink from './ClientLink'; import {clientRedirect, clientPermanentRedirect} from './redirects'; @@ -11,17 +11,25 @@ import useBasePathname from './useBasePathname'; import useBaseRouter from './useBaseRouter'; export default function createSharedPathnamesNavigation< - const AppLocales extends Locales ->(routing?: RoutingConfigSharedNavigation) { + AppLocales extends Locales, + AppLocalePrefixMode extends LocalePrefixMode, + AppDomains extends DomainsConfig = never +>( + routing?: RoutingConfigSharedNavigation< + AppLocales, + AppLocalePrefixMode, + AppDomains + > +) { const localePrefix = receiveLocalePrefixConfig(routing?.localePrefix); type LinkProps = Omit< - ComponentProps>, + ComponentProps>, 'localePrefix' >; function Link(props: LinkProps, ref: LinkProps['ref']) { return ( - + ref={ref} localePrefix={localePrefix} {...props} @@ -54,7 +62,7 @@ export default function createSharedPathnamesNavigation< } function useRouter() { - return useBaseRouter(localePrefix); + return useBaseRouter(localePrefix); } return { diff --git a/packages/next-intl/src/navigation/react-client/useBasePathname.tsx b/packages/next-intl/src/navigation/react-client/useBasePathname.tsx index 5f2184e92..105ef56e1 100644 --- a/packages/next-intl/src/navigation/react-client/useBasePathname.tsx +++ b/packages/next-intl/src/navigation/react-client/useBasePathname.tsx @@ -1,16 +1,21 @@ import {usePathname as useNextPathname} from 'next/navigation'; import {useMemo} from 'react'; import useLocale from '../../react-client/useLocale'; -import {Locales, LocalePrefixConfigVerbose} from '../../routing/types'; +import { + Locales, + LocalePrefixConfigVerbose, + LocalePrefixMode +} from '../../routing/types'; import { getLocalePrefix, hasPathnamePrefixed, unprefixPathname } from '../../shared/utils'; -export default function useBasePathname( - localePrefix: LocalePrefixConfigVerbose -) { +export default function useBasePathname< + AppLocales extends Locales, + AppLocalePrefixMode extends LocalePrefixMode +>(localePrefix: LocalePrefixConfigVerbose) { // The types aren't entirely correct here. Outside of Next.js // `useParams` can be called, but the return type is `null`. diff --git a/packages/next-intl/src/navigation/react-client/useBaseRouter.tsx b/packages/next-intl/src/navigation/react-client/useBaseRouter.tsx index 9be7e2f49..8f3a6fad0 100644 --- a/packages/next-intl/src/navigation/react-client/useBaseRouter.tsx +++ b/packages/next-intl/src/navigation/react-client/useBaseRouter.tsx @@ -1,7 +1,11 @@ import {useRouter as useNextRouter, usePathname} from 'next/navigation'; import {useMemo} from 'react'; import useLocale from '../../react-client/useLocale'; -import {Locales, LocalePrefixConfigVerbose} from '../../routing/types'; +import { + Locales, + LocalePrefixConfigVerbose, + LocalePrefixMode +} from '../../routing/types'; import {getLocalePrefix, localizeHref} from '../../shared/utils'; import syncLocaleCookie from '../shared/syncLocaleCookie'; import {getBasePath} from '../shared/utils'; @@ -29,9 +33,10 @@ type IntlNavigateOptions = { * router.push('/about', {locale: 'de'}); * ``` */ -export default function useBaseRouter( - localePrefix: LocalePrefixConfigVerbose -) { +export default function useBaseRouter< + AppLocales extends Locales, + AppLocalePrefixMode extends LocalePrefixMode +>(localePrefix: LocalePrefixConfigVerbose) { const router = useNextRouter(); const locale = useLocale(); const pathname = usePathname(); diff --git a/packages/next-intl/src/navigation/react-server/ServerLink.tsx b/packages/next-intl/src/navigation/react-server/ServerLink.tsx index 612c95870..39de1ee61 100644 --- a/packages/next-intl/src/navigation/react-server/ServerLink.tsx +++ b/packages/next-intl/src/navigation/react-server/ServerLink.tsx @@ -1,24 +1,30 @@ import React, {ComponentProps} from 'react'; -import {Locales, LocalePrefixConfigVerbose} from '../../routing/types'; +import { + Locales, + LocalePrefixConfigVerbose, + LocalePrefixMode +} from '../../routing/types'; import {getLocale} from '../../server.react-server'; import {getLocalePrefix} from '../../shared/utils'; import LegacyBaseLink from '../shared/LegacyBaseLink'; // Only used by legacy navigation APIs, can be removed when they are removed -type Props = Omit< +type Props< + AppLocales extends Locales, + AppLocalePrefixMode extends LocalePrefixMode +> = Omit< ComponentProps, 'locale' | 'prefix' | 'localePrefixMode' > & { locale?: AppLocales[number]; - localePrefix: LocalePrefixConfigVerbose; + localePrefix: LocalePrefixConfigVerbose; }; -export default async function ServerLink({ - locale, - localePrefix, - ...rest -}: Props) { +export default async function ServerLink< + AppLocales extends Locales, + AppLocalePrefixMode extends LocalePrefixMode +>({locale, localePrefix, ...rest}: Props) { const finalLocale = locale || (await getLocale()); const prefix = getLocalePrefix(finalLocale, localePrefix); diff --git a/packages/next-intl/src/navigation/react-server/createLocalizedPathnamesNavigation.tsx b/packages/next-intl/src/navigation/react-server/createLocalizedPathnamesNavigation.tsx index ba63dea8c..808f45484 100644 --- a/packages/next-intl/src/navigation/react-server/createLocalizedPathnamesNavigation.tsx +++ b/packages/next-intl/src/navigation/react-server/createLocalizedPathnamesNavigation.tsx @@ -3,7 +3,12 @@ import { receiveRoutingConfig, RoutingConfigLocalizedNavigation } from '../../routing/config'; -import {Locales, Pathnames} from '../../routing/types'; +import { + DomainsConfig, + LocalePrefixMode, + Locales, + Pathnames +} from '../../routing/types'; import {getRequestLocale} from '../../server/react-server/RequestLocale'; import {ParametersExceptFirst} from '../../shared/types'; import { @@ -17,8 +22,17 @@ import {serverPermanentRedirect, serverRedirect} from './redirects'; export default function createLocalizedPathnamesNavigation< AppLocales extends Locales, - AppPathnames extends Pathnames ->(routing: RoutingConfigLocalizedNavigation) { + AppLocalePrefixMode extends LocalePrefixMode = 'always', + AppPathnames extends Pathnames = never, + AppDomains extends DomainsConfig = never +>( + routing: RoutingConfigLocalizedNavigation< + AppLocales, + AppLocalePrefixMode, + AppPathnames, + AppDomains + > +) { const config = receiveRoutingConfig(routing); type LinkProps = Omit< diff --git a/packages/next-intl/src/navigation/react-server/createNavigation.tsx b/packages/next-intl/src/navigation/react-server/createNavigation.tsx index 67e8afa56..8e5f5c0bc 100644 --- a/packages/next-intl/src/navigation/react-server/createNavigation.tsx +++ b/packages/next-intl/src/navigation/react-server/createNavigation.tsx @@ -2,17 +2,35 @@ import { RoutingConfigLocalizedNavigation, RoutingConfigSharedNavigation } from '../../routing/config'; -import {Locales, Pathnames} from '../../routing/types'; +import { + DomainsConfig, + LocalePrefixMode, + Locales, + Pathnames +} from '../../routing/types'; import {getRequestLocale} from '../../server/react-server/RequestLocale'; import createSharedNavigationFns from '../shared/createSharedNavigationFns'; export default function createNavigation< const AppLocales extends Locales, - const AppPathnames extends Pathnames = never + const AppLocalePrefixMode extends LocalePrefixMode = 'always', + const AppPathnames extends Pathnames = never, + const AppDomains extends DomainsConfig = never >( routing?: [AppPathnames] extends [never] - ? RoutingConfigSharedNavigation | undefined - : RoutingConfigLocalizedNavigation + ? + | RoutingConfigSharedNavigation< + AppLocales, + AppLocalePrefixMode, + AppDomains + > + | undefined + : RoutingConfigLocalizedNavigation< + AppLocales, + AppLocalePrefixMode, + AppPathnames, + AppDomains + > ) { type Locale = AppLocales extends never ? string : AppLocales[number]; diff --git a/packages/next-intl/src/navigation/react-server/createSharedPathnamesNavigation.tsx b/packages/next-intl/src/navigation/react-server/createSharedPathnamesNavigation.tsx index 687f29d7e..a9da79401 100644 --- a/packages/next-intl/src/navigation/react-server/createSharedPathnamesNavigation.tsx +++ b/packages/next-intl/src/navigation/react-server/createSharedPathnamesNavigation.tsx @@ -3,14 +3,22 @@ import { receiveLocalePrefixConfig, RoutingConfigSharedNavigation } from '../../routing/config'; -import {Locales} from '../../routing/types'; +import {DomainsConfig, LocalePrefixMode, Locales} from '../../routing/types'; import {ParametersExceptFirst} from '../../shared/types'; import ServerLink from './ServerLink'; import {serverPermanentRedirect, serverRedirect} from './redirects'; export default function createSharedPathnamesNavigation< - AppLocales extends Locales ->(routing?: RoutingConfigSharedNavigation) { + AppLocales extends Locales, + AppLocalePrefixMode extends LocalePrefixMode, + AppDomains extends DomainsConfig = never +>( + routing?: RoutingConfigSharedNavigation< + AppLocales, + AppLocalePrefixMode, + AppDomains + > +) { const localePrefix = receiveLocalePrefixConfig(routing?.localePrefix); function notSupported(hookName: string) { @@ -22,9 +30,17 @@ export default function createSharedPathnamesNavigation< } function Link( - props: Omit>, 'localePrefix'> + props: Omit< + ComponentProps>, + 'localePrefix' + > ) { - return localePrefix={localePrefix} {...props} />; + return ( + + localePrefix={localePrefix} + {...props} + /> + ); } function redirect( diff --git a/packages/next-intl/src/navigation/shared/createSharedNavigationFns.tsx b/packages/next-intl/src/navigation/shared/createSharedNavigationFns.tsx index 16f55df9b..6c481f85a 100644 --- a/packages/next-intl/src/navigation/shared/createSharedNavigationFns.tsx +++ b/packages/next-intl/src/navigation/shared/createSharedNavigationFns.tsx @@ -5,11 +5,16 @@ import { import React, {ComponentProps} from 'react'; import { receiveRoutingConfig, - ResolvedRoutingConfig, RoutingConfigLocalizedNavigation, RoutingConfigSharedNavigation } from '../../routing/config'; -import {DomainConfig, Locales, Pathnames} from '../../routing/types'; +import { + DomainConfig, + DomainsConfig, + LocalePrefixMode, + Locales, + Pathnames +} from '../../routing/types'; import {ParametersExceptFirst} from '../../shared/types'; import {isLocalizableHref} from '../../shared/utils'; import BaseLink from './BaseLink'; @@ -29,22 +34,29 @@ import { */ export default function createSharedNavigationFns< const AppLocales extends Locales, - const AppPathnames extends Pathnames = never + const AppPathnames extends Pathnames = never, + const AppLocalePrefixMode extends LocalePrefixMode = 'always', + const AppDomains extends DomainsConfig = never >( getLocale: () => AppLocales extends never ? string : AppLocales[number], routing?: [AppPathnames] extends [never] - ? RoutingConfigSharedNavigation | undefined - : RoutingConfigLocalizedNavigation + ? + | RoutingConfigSharedNavigation< + AppLocales, + AppLocalePrefixMode, + AppDomains + > + | undefined + : RoutingConfigLocalizedNavigation< + AppLocales, + AppLocalePrefixMode, + AppPathnames, + AppDomains + > ) { type Locale = ReturnType; - const config = receiveRoutingConfig( - routing || {} - ) as typeof routing extends undefined - ? Pick, 'localePrefix'> - : [AppPathnames] extends [never] - ? ResolvedRoutingConfig - : ResolvedRoutingConfig; + const config = receiveRoutingConfig(routing || {}); if (process.env.NODE_ENV !== 'production') { validateReceivedConfig(config); } @@ -92,9 +104,9 @@ export default function createSharedNavigationFns< const curLocale = getLocale(); const finalPathname = isLocalizable ? getPathname( + // @ts-expect-error -- This is ok { locale: locale || curLocale, - // @ts-expect-error -- This is ok href: pathnames == null ? pathname : {pathname, params} }, locale != null || forcePrefixSsr || undefined @@ -126,9 +138,9 @@ export default function createSharedNavigationFns< {} ), pathname: getPathname( + // @ts-expect-error -- This is ok { locale: curLocale, - // @ts-expect-error -- This is ok href: pathnames == null ? pathname : {pathname, params} }, false @@ -142,21 +154,26 @@ export default function createSharedNavigationFns< } function getPathname( - { - domain, - href, - locale - }: { - locale: Locale; + args: { href: [AppPathnames] extends [never] ? string | {pathname: string; query?: QueryParams} : HrefOrHrefWithParams; - /** In case you're using `localePrefix: 'as-needed'` in combination with `domains`, the `defaultLocale` can differ by domain and therefore the locales that need to be prefixed can differ as well. For this particular case, this parameter should be provided in order to compute the correct pathname. Note that the actual domain is not part of the result, but only the pathname is returned. */ - domain?: string; - }, + locale: Locale; + } & (typeof routing extends undefined + ? {} + : AppLocalePrefixMode extends 'as-needed' + ? [AppDomains] extends [never] + ? {} + : { + /** In case you're using `localePrefix: 'as-needed'` in combination with `domains`, the `defaultLocale` can differ by domain and therefore the locales that need to be prefixed can differ as well. For this particular case, this parameter should be provided in order to compute the correct pathname. Note that the actual domain is not part of the result, but only the pathname is returned. */ + domain: AppDomains[number]['domain']; + } + : {}), /** @private */ _forcePrefix?: boolean ) { + const {href, locale} = args; + let pathname: string; if (pathnames == null) { if (typeof href === 'object') { @@ -177,11 +194,14 @@ export default function createSharedNavigationFns< }); } - // TODO: There might be only one shot here, for as-needed - // would be reading `host`, but that breaks SSG. If you want - // to get the first shot right, pass a `domain` here (then - // the user opts into dynamic rendering) - return applyPathnamePrefix(pathname, locale, config, domain, _forcePrefix); + return applyPathnamePrefix( + pathname, + locale, + config, + // @ts-expect-error -- This is ok + args.domain, + _forcePrefix + ); } function getRedirectFn( @@ -193,7 +213,14 @@ export default function createSharedNavigationFns< ) { const locale = getLocale(); - return fn(getPathname({href, locale}, forcePrefixSsr), ...args); + return fn( + getPathname( + // @ts-expect-error -- This is ok + {href, locale}, + forcePrefixSsr + ), + ...args + ); }; } diff --git a/packages/next-intl/src/navigation/shared/redirects.tsx b/packages/next-intl/src/navigation/shared/redirects.tsx index 9720825b0..96e135ec4 100644 --- a/packages/next-intl/src/navigation/shared/redirects.tsx +++ b/packages/next-intl/src/navigation/shared/redirects.tsx @@ -2,7 +2,11 @@ import { permanentRedirect as nextPermanentRedirect, redirect as nextRedirect } from 'next/navigation'; -import {Locales, LocalePrefixConfigVerbose} from '../../routing/types'; +import { + Locales, + LocalePrefixConfigVerbose, + LocalePrefixMode +} from '../../routing/types'; import {ParametersExceptFirst} from '../../shared/types'; import { getLocalePrefix, @@ -11,11 +15,14 @@ import { } from '../../shared/utils'; function createRedirectFn(redirectFn: typeof nextRedirect) { - return function baseRedirect( + return function baseRedirect< + AppLocales extends Locales, + AppLocalePrefixMode extends LocalePrefixMode + >( params: { pathname: string; locale: Locales[number]; - localePrefix: LocalePrefixConfigVerbose; + localePrefix: LocalePrefixConfigVerbose; }, ...args: ParametersExceptFirst ) { diff --git a/packages/next-intl/src/navigation/shared/utils.tsx b/packages/next-intl/src/navigation/shared/utils.tsx index 600d92c33..694553fd1 100644 --- a/packages/next-intl/src/navigation/shared/utils.tsx +++ b/packages/next-intl/src/navigation/shared/utils.tsx @@ -1,7 +1,12 @@ import type {ParsedUrlQueryInput} from 'node:querystring'; import type {UrlObject} from 'url'; import {ResolvedRoutingConfig} from '../../routing/config'; -import {Locales, Pathnames} from '../../routing/types'; +import { + DomainsConfig, + LocalePrefixMode, + Locales, + Pathnames +} from '../../routing/types'; import { matchesPathname, getSortedPathnames, @@ -217,11 +222,34 @@ export function getBasePath( } } -export function applyPathnamePrefix( +export function applyPathnamePrefix< + AppLocales extends Locales, + AppLocalePrefixMode extends LocalePrefixMode, + AppPathnames extends Pathnames | undefined, + AppDomains extends DomainsConfig | undefined +>( pathname: string, locale: Locales[number], - routing: Pick, 'localePrefix' | 'domains'> & - Partial, 'defaultLocale'>>, + routing: Pick< + ResolvedRoutingConfig< + AppLocales, + AppLocalePrefixMode, + AppPathnames, + AppDomains + >, + 'localePrefix' | 'domains' + > & + Partial< + Pick< + ResolvedRoutingConfig< + AppLocales, + AppLocalePrefixMode, + AppPathnames, + AppDomains + >, + 'defaultLocale' + > + >, domain?: string, force?: boolean ): string { @@ -234,7 +262,7 @@ export function applyPathnamePrefix( if (mode === 'always') { shouldPrefix = true; } else if (mode === 'as-needed') { - let {defaultLocale} = routing; + let defaultLocale: AppLocales[number] | undefined = routing.defaultLocale; if (routing.domains) { const domainConfig = routing.domains.find( @@ -268,10 +296,20 @@ export function applyPathnamePrefix( : pathname; } -export function validateReceivedConfig( +export function validateReceivedConfig< + AppLocales extends Locales, + AppLocalePrefixMode extends LocalePrefixMode, + AppPathnames extends Pathnames | undefined, + AppDomains extends DomainsConfig | undefined +>( config: Partial< Pick< - ResolvedRoutingConfig>, + ResolvedRoutingConfig< + AppLocales, + AppLocalePrefixMode, + AppPathnames, + AppDomains + >, 'defaultLocale' | 'localePrefix' > > diff --git a/packages/next-intl/src/react-client/index.tsx b/packages/next-intl/src/react-client/index.tsx index a2f4f5ee8..86fd21d3b 100644 --- a/packages/next-intl/src/react-client/index.tsx +++ b/packages/next-intl/src/react-client/index.tsx @@ -15,7 +15,6 @@ import { export * from 'use-intl'; -// eslint-disable-next-line @typescript-eslint/ban-types function callHook(name: string, hook: Function) { return (...args: Array) => { try { diff --git a/packages/next-intl/src/react-server/useTranslations.test.tsx b/packages/next-intl/src/react-server/useTranslations.test.tsx index 396841934..93ee9aa24 100644 --- a/packages/next-intl/src/react-server/useTranslations.test.tsx +++ b/packages/next-intl/src/react-server/useTranslations.test.tsx @@ -5,6 +5,7 @@ import {createTranslator, useTranslations} from '.'; vi.mock('../../src/server/react-server/createRequestConfig', () => ({ default: async () => ({ + locale: 'en', messages: { A: { title: 'A' diff --git a/packages/next-intl/src/routing/config.tsx b/packages/next-intl/src/routing/config.tsx index eefa31bd0..6bdcf067c 100644 --- a/packages/next-intl/src/routing/config.tsx +++ b/packages/next-intl/src/routing/config.tsx @@ -3,12 +3,15 @@ import { LocalePrefix, LocalePrefixConfigVerbose, DomainsConfig, - Pathnames + Pathnames, + LocalePrefixMode } from './types'; export type RoutingConfig< AppLocales extends Locales, - AppPathnames extends Pathnames + AppLocalePrefixMode extends LocalePrefixMode, + AppPathnames extends Pathnames | undefined, + AppDomains extends DomainsConfig | undefined > = { /** * All available locales. @@ -26,16 +29,15 @@ export type RoutingConfig< * Configures whether and which prefix is shown for a given locale. * @see https://next-intl-docs.vercel.app/docs/routing#locale-prefix **/ - localePrefix?: LocalePrefix; + localePrefix?: LocalePrefix; /** * Can be used to change the locale handling per domain. * @see https://next-intl-docs.vercel.app/docs/routing#domains **/ - domains?: DomainsConfig; + domains?: AppDomains; } & ([AppPathnames] extends [never] ? // https://discord.com/channels/997886693233393714/1278008400533520434 - // eslint-disable-next-line @typescript-eslint/ban-types {} : { /** @@ -45,45 +47,73 @@ export type RoutingConfig< pathnames: AppPathnames; }); -export type RoutingConfigSharedNavigation = Omit< - RoutingConfig, +export type RoutingConfigSharedNavigation< + AppLocales extends Locales, + AppLocalePrefixMode extends LocalePrefixMode, + AppDomains extends DomainsConfig = never +> = Omit< + RoutingConfig, 'defaultLocale' | 'locales' | 'pathnames' > & - Partial, 'defaultLocale' | 'locales'>>; + Partial< + Pick< + RoutingConfig, + 'defaultLocale' | 'locales' + > + >; export type RoutingConfigLocalizedNavigation< AppLocales extends Locales, - AppPathnames extends Pathnames + AppLocalePrefixMode extends LocalePrefixMode, + AppPathnames extends Pathnames, + AppDomains extends DomainsConfig = never > = Omit< - RoutingConfig, + RoutingConfig, 'defaultLocale' | 'pathnames' > & - Partial, 'defaultLocale'>> & { + Partial< + Pick< + RoutingConfig, + 'defaultLocale' + > + > & { pathnames: AppPathnames; }; export type ResolvedRoutingConfig< AppLocales extends Locales, - AppPathnames extends Pathnames = never -> = Omit, 'localePrefix'> & { - localePrefix: LocalePrefixConfigVerbose; + AppLocalePrefixMode extends LocalePrefixMode, + AppPathnames extends Pathnames | undefined, + AppDomains extends DomainsConfig | undefined +> = Omit< + RoutingConfig, + 'localePrefix' +> & { + localePrefix: LocalePrefixConfigVerbose; }; export function receiveRoutingConfig< AppLocales extends Locales, - AppPathnames extends Pathnames, - Config extends Partial> + AppLocalePrefixMode extends LocalePrefixMode, + AppPathnames extends Pathnames | undefined, + AppDomains extends DomainsConfig | undefined, + Config extends Partial< + RoutingConfig + > >(input: Config) { return { - ...input, + ...(input as Omit), localePrefix: receiveLocalePrefixConfig(input?.localePrefix) }; } -export function receiveLocalePrefixConfig( - localePrefix?: LocalePrefix -): LocalePrefixConfigVerbose { - return typeof localePrefix === 'object' - ? localePrefix - : {mode: localePrefix || 'always'}; +export function receiveLocalePrefixConfig< + AppLocales extends Locales, + AppLocalePrefixMode extends LocalePrefixMode +>(localePrefix?: LocalePrefix) { + return ( + typeof localePrefix === 'object' + ? localePrefix + : {mode: localePrefix || 'always'} + ) as LocalePrefixConfigVerbose; } diff --git a/packages/next-intl/src/routing/defineRouting.tsx b/packages/next-intl/src/routing/defineRouting.tsx index b035f5ffd..d470db867 100644 --- a/packages/next-intl/src/routing/defineRouting.tsx +++ b/packages/next-intl/src/routing/defineRouting.tsx @@ -1,9 +1,18 @@ import {RoutingConfig} from './config'; -import {Locales, Pathnames} from './types'; +import {DomainsConfig, LocalePrefixMode, Locales, Pathnames} from './types'; export default function defineRouting< const AppLocales extends Locales, - const AppPathnames extends Pathnames = never ->(config: RoutingConfig) { + const AppLocalePrefixMode extends LocalePrefixMode = 'always', + const AppPathnames extends Pathnames = never, + const AppDomains extends DomainsConfig = never +>( + config: RoutingConfig< + AppLocales, + AppLocalePrefixMode, + AppPathnames, + AppDomains + > +) { return config; } diff --git a/packages/next-intl/src/routing/types.tsx b/packages/next-intl/src/routing/types.tsx index 4e9b16368..d47c5ee2e 100644 --- a/packages/next-intl/src/routing/types.tsx +++ b/packages/next-intl/src/routing/types.tsx @@ -8,22 +8,29 @@ export type LocalePrefixes = Partial< Record >; -export type LocalePrefixConfigVerbose = - | { +export type LocalePrefixConfigVerbose< + AppLocales extends Locales, + AppLocalePrefixMode extends LocalePrefixMode +> = AppLocalePrefixMode extends 'always' + ? { mode: 'always'; prefixes?: LocalePrefixes; } - | { - mode: 'as-needed'; - prefixes?: LocalePrefixes; - } - | { - mode: 'never'; - }; - -export type LocalePrefix = - | LocalePrefixMode - | LocalePrefixConfigVerbose; + : AppLocalePrefixMode extends 'as-needed' + ? { + mode: 'as-needed'; + prefixes?: LocalePrefixes; + } + : { + mode: 'never'; + }; + +export type LocalePrefix< + AppLocales extends Locales = [], + AppLocalePrefixMode extends LocalePrefixMode = 'always' +> = + | AppLocalePrefixMode + | LocalePrefixConfigVerbose; export type Pathnames = Record< Pathname, diff --git a/packages/next-intl/src/shared/utils.tsx b/packages/next-intl/src/shared/utils.tsx index 0a9693538..ad9b73f17 100644 --- a/packages/next-intl/src/shared/utils.tsx +++ b/packages/next-intl/src/shared/utils.tsx @@ -1,7 +1,11 @@ import {UrlObject} from 'url'; import NextLink from 'next/link'; import {ComponentProps} from 'react'; -import {Locales, LocalePrefixConfigVerbose} from '../routing/types'; +import { + Locales, + LocalePrefixConfigVerbose, + LocalePrefixMode +} from '../routing/types'; type Href = ComponentProps['href']; @@ -140,9 +144,12 @@ export function matchesPathname( return regex.test(normalizedPathname); } -export function getLocalePrefix( +export function getLocalePrefix< + AppLocales extends Locales, + AppLocalePrefixMode extends LocalePrefixMode +>( locale: AppLocales[number], - localePrefix: LocalePrefixConfigVerbose + localePrefix: LocalePrefixConfigVerbose ) { return ( (localePrefix.mode !== 'never' && localePrefix.prefixes?.[locale]) || From 82eaff3fc9142d1dacacd91e555fbac86492701d Mon Sep 17 00:00:00 2001 From: Jan Amann Date: Thu, 26 Sep 2024 12:02:59 +0200 Subject: [PATCH 38/62] remove _forcePrefix from public api --- .../next-intl/src/navigation/createNavigation.test.tsx | 8 ++++++++ .../navigation/shared/createSharedNavigationFns.tsx | 10 +++++++--- 2 files changed, 15 insertions(+), 3 deletions(-) diff --git a/packages/next-intl/src/navigation/createNavigation.test.tsx b/packages/next-intl/src/navigation/createNavigation.test.tsx index eaa799e61..b7923a493 100644 --- a/packages/next-intl/src/navigation/createNavigation.test.tsx +++ b/packages/next-intl/src/navigation/createNavigation.test.tsx @@ -282,6 +282,14 @@ describe.each([ // @ts-expect-error -- Domain is not supported getPathname({locale: 'en', href: '/', domain: 'example.com'}); }); + + it('does not accept the _forcePrefix flag', () => { + getPathname( + {locale: 'en', href: '/'}, + // @ts-expect-error -- Not supported + true + ); + }); }); describe.each([ diff --git a/packages/next-intl/src/navigation/shared/createSharedNavigationFns.tsx b/packages/next-intl/src/navigation/shared/createSharedNavigationFns.tsx index 6c481f85a..fd0ecf429 100644 --- a/packages/next-intl/src/navigation/shared/createSharedNavigationFns.tsx +++ b/packages/next-intl/src/navigation/shared/createSharedNavigationFns.tsx @@ -169,7 +169,7 @@ export default function createSharedNavigationFns< domain: AppDomains[number]['domain']; } : {}), - /** @private */ + /** @private Removed in types returned below */ _forcePrefix?: boolean ) { const {href, locale} = args; @@ -228,10 +228,14 @@ export default function createSharedNavigationFns< const permanentRedirect = getRedirectFn(nextPermanentRedirect); return { + config, Link, redirect, permanentRedirect, - getPathname, - config + + // Remove `_forcePrefix` from public API + getPathname: getPathname as ( + args: Parameters[0] + ) => string }; } From a215028d2dccfa94a9f4d6207f315470d6406ff0 Mon Sep 17 00:00:00 2001 From: Jan Amann Date: Thu, 26 Sep 2024 13:51:57 +0200 Subject: [PATCH 39/62] prettify link props --- .../react-client/createNavigation.tsx | 3 ++- .../shared/createSharedNavigationFns.tsx | 22 ++++++++++--------- packages/next-intl/src/shared/types.tsx | 5 +++++ 3 files changed, 19 insertions(+), 11 deletions(-) diff --git a/packages/next-intl/src/navigation/react-client/createNavigation.tsx b/packages/next-intl/src/navigation/react-client/createNavigation.tsx index 947209950..f3f7fe133 100644 --- a/packages/next-intl/src/navigation/react-client/createNavigation.tsx +++ b/packages/next-intl/src/navigation/react-client/createNavigation.tsx @@ -14,6 +14,7 @@ import { Locales, Pathnames } from '../../routing/types'; +import {Prettify} from '../../shared/types'; import createSharedNavigationFns from '../shared/createSharedNavigationFns'; import syncLocaleCookie from '../shared/syncLocaleCookie'; import {getRoute} from '../shared/utils'; @@ -83,7 +84,7 @@ export default function createNavigation< return ; } const LinkWithRef = forwardRef(Link) as ( - props: LinkProps & {ref?: LinkProps['ref']} + props: Prettify ) => ReactElement; (LinkWithRef as any).displayName = 'Link'; diff --git a/packages/next-intl/src/navigation/shared/createSharedNavigationFns.tsx b/packages/next-intl/src/navigation/shared/createSharedNavigationFns.tsx index fd0ecf429..c610089c9 100644 --- a/packages/next-intl/src/navigation/shared/createSharedNavigationFns.tsx +++ b/packages/next-intl/src/navigation/shared/createSharedNavigationFns.tsx @@ -15,7 +15,7 @@ import { Locales, Pathnames } from '../../routing/types'; -import {ParametersExceptFirst} from '../../shared/types'; +import {ParametersExceptFirst, Prettify} from '../../shared/types'; import {isLocalizableHref} from '../../shared/utils'; import BaseLink from './BaseLink'; import { @@ -75,15 +75,17 @@ export default function createSharedNavigationFns< (config.localePrefix.mode === 'as-needed' && 'domains' in config) || undefined; - type LinkProps = Omit< - ComponentProps, - 'href' | 'localePrefix' - > & { - href: [AppPathnames] extends [never] - ? ComponentProps['href'] - : HrefOrUrlObjectWithParams; - locale?: Locale; - }; + type LinkProps = Prettify< + Omit< + ComponentProps, + 'href' | 'localePrefix' | 'unprefixConfig' + > & { + href: [AppPathnames] extends [never] + ? ComponentProps['href'] + : HrefOrUrlObjectWithParams; + locale?: Locale; + } + >; function Link({ href, locale, diff --git a/packages/next-intl/src/shared/types.tsx b/packages/next-intl/src/shared/types.tsx index 2efd9d1a2..4e27dc0d1 100644 --- a/packages/next-intl/src/shared/types.tsx +++ b/packages/next-intl/src/shared/types.tsx @@ -12,3 +12,8 @@ export type ParametersExceptFirstTwo = Fn extends ( ) => any ? R : never; + +// https://www.totaltypescript.com/concepts/the-prettify-helper +export type Prettify = { + [Key in keyof Type]: Type[Key]; +} & {}; From c9398e1d96df6bbf7e40578258fc2d56dd661326 Mon Sep 17 00:00:00 2001 From: Jan Amann Date: Thu, 26 Sep 2024 13:59:31 +0200 Subject: [PATCH 40/62] more tests --- .../react-client/createNavigation.test.tsx | 46 +++++++++++++++++++ 1 file changed, 46 insertions(+) diff --git a/packages/next-intl/src/navigation/react-client/createNavigation.test.tsx b/packages/next-intl/src/navigation/react-client/createNavigation.test.tsx index 38e49ff7a..5e6c5fe9a 100644 --- a/packages/next-intl/src/navigation/react-client/createNavigation.test.tsx +++ b/packages/next-intl/src/navigation/react-client/createNavigation.test.tsx @@ -369,6 +369,52 @@ describe("localePrefix: 'as-needed'", () => { }); }); +describe("localePrefix: 'as-needed', with `basePath` and `domains`", () => { + const {useRouter} = createNavigation({ + locales, + defaultLocale, + domains, + localePrefix: 'as-needed' + }); + + describe('useRouter', () => { + const invokeRouter = getInvokeRouter(useRouter); + + describe('example.com, defaultLocale: "en"', () => { + beforeEach(() => { + mockLocation({pathname: '/base/path/about', host: 'example.com'}); + }); + + it('can compute the correct pathname when the default locale on the current domain matches the current locale', () => { + invokeRouter((router) => router.push('/test')); + expect(useNextRouter().push).toHaveBeenCalledWith('/test'); + }); + + it('can compute the correct pathname when the default locale on the current domain does not match the current locale', () => { + invokeRouter((router) => router.push('/test', {locale: 'de'})); + expect(useNextRouter().push).toHaveBeenCalledWith('/de/test'); + }); + }); + + describe('example.de, defaultLocale: "de"', () => { + beforeEach(() => { + mockCurrentLocale('de'); + mockLocation({pathname: '/base/path/about', host: 'example.de'}); + }); + + it('can compute the correct pathname when the default locale on the current domain matches the current locale', () => { + invokeRouter((router) => router.push('/test')); + expect(useNextRouter().push).toHaveBeenCalledWith('/test'); + }); + + it('can compute the correct pathname when the default locale on the current domain does not match the current locale', () => { + invokeRouter((router) => router.push('/test', {locale: 'en'})); + expect(useNextRouter().push).toHaveBeenCalledWith('/en/test'); + }); + }); + }); +}); + describe("localePrefix: 'as-needed', with `domains`", () => { const {usePathname, useRouter} = createNavigation({ locales, From c02104f9ffe2685decee692df3d0b870e7cf1126 Mon Sep 17 00:00:00 2001 From: Jan Amann Date: Thu, 26 Sep 2024 14:39:26 +0200 Subject: [PATCH 41/62] JSDoc, cleanup --- .../navigation/react-client/createNavigation.tsx | 14 +++++++++++--- .../shared/createSharedNavigationFns.tsx | 9 ++++++++- packages/next-intl/src/shared/utils.tsx | 6 +----- 3 files changed, 20 insertions(+), 9 deletions(-) diff --git a/packages/next-intl/src/navigation/react-client/createNavigation.tsx b/packages/next-intl/src/navigation/react-client/createNavigation.tsx index f3f7fe133..f348ae30c 100644 --- a/packages/next-intl/src/navigation/react-client/createNavigation.tsx +++ b/packages/next-intl/src/navigation/react-client/createNavigation.tsx @@ -44,8 +44,6 @@ export default function createNavigation< type Locale = AppLocales extends never ? string : AppLocales[number]; function useTypedLocale() { - // eslint-disable-next-line react-compiler/react-compiler - // eslint-disable-next-line react-hooks/rules-of-hooks -- Reading from context here is fine, since this must always be called during render (redirect, useRouter) return useLocale() as Locale; } @@ -56,6 +54,7 @@ export default function createNavigation< ...redirects } = createSharedNavigationFns(useTypedLocale, routing); + /** @see https://next-intl-docs.vercel.app/docs/routing/navigation#usepathname */ function usePathname(): [AppPathnames] extends [never] ? string : keyof AppPathnames { @@ -125,14 +124,17 @@ export default function createNavigation< return { ...router, + /** @see https://next-intl-docs.vercel.app/docs/routing/navigation#userouter */ push: createHandler< Parameters[1], typeof router.push >(router.push), + /** @see https://next-intl-docs.vercel.app/docs/routing/navigation#userouter */ replace: createHandler< Parameters[1], typeof router.replace >(router.replace), + /** @see https://next-intl-docs.vercel.app/docs/routing/navigation#userouter */ prefetch: createHandler< Parameters[1], typeof router.prefetch @@ -141,5 +143,11 @@ export default function createNavigation< }, [curLocale, nextPathname, router]); } - return {...redirects, Link: LinkWithRef, usePathname, useRouter, getPathname}; + return { + ...redirects, + Link: LinkWithRef, + usePathname, + useRouter, + getPathname + }; } diff --git a/packages/next-intl/src/navigation/shared/createSharedNavigationFns.tsx b/packages/next-intl/src/navigation/shared/createSharedNavigationFns.tsx index c610089c9..f3b7e47f9 100644 --- a/packages/next-intl/src/navigation/shared/createSharedNavigationFns.tsx +++ b/packages/next-intl/src/navigation/shared/createSharedNavigationFns.tsx @@ -80,9 +80,11 @@ export default function createSharedNavigationFns< ComponentProps, 'href' | 'localePrefix' | 'unprefixConfig' > & { + /** @see https://next-intl-docs.vercel.app/docs/routing/navigation#link */ href: [AppPathnames] extends [never] ? ComponentProps['href'] : HrefOrUrlObjectWithParams; + /** @see https://next-intl-docs.vercel.app/docs/routing/navigation#link */ locale?: Locale; } >; @@ -157,6 +159,7 @@ export default function createSharedNavigationFns< function getPathname( args: { + /** @see https://next-intl-docs.vercel.app/docs/routing/navigation#getpathname */ href: [AppPathnames] extends [never] ? string | {pathname: string; query?: QueryParams} : HrefOrHrefWithParams; @@ -167,7 +170,10 @@ export default function createSharedNavigationFns< ? [AppDomains] extends [never] ? {} : { - /** In case you're using `localePrefix: 'as-needed'` in combination with `domains`, the `defaultLocale` can differ by domain and therefore the locales that need to be prefixed can differ as well. For this particular case, this parameter should be provided in order to compute the correct pathname. Note that the actual domain is not part of the result, but only the pathname is returned. */ + /** + * In case you're using `localePrefix: 'as-needed'` in combination with `domains`, the `defaultLocale` can differ by domain and therefore the locales that need to be prefixed can differ as well. For this particular case, this parameter should be provided in order to compute the correct pathname. Note that the actual domain is not part of the result, but only the pathname is returned. + * @see https://next-intl-docs.vercel.app/docs/routing/navigation#getpathname + */ domain: AppDomains[number]['domain']; } : {}), @@ -209,6 +215,7 @@ export default function createSharedNavigationFns< function getRedirectFn( fn: typeof nextRedirect | typeof nextPermanentRedirect ) { + /** @see https://next-intl-docs.vercel.app/docs/routing/navigation#redirect */ return function redirectFn( href: Parameters[0]['href'], ...args: ParametersExceptFirst diff --git a/packages/next-intl/src/shared/utils.tsx b/packages/next-intl/src/shared/utils.tsx index ad9b73f17..58b92ac92 100644 --- a/packages/next-intl/src/shared/utils.tsx +++ b/packages/next-intl/src/shared/utils.tsx @@ -9,13 +9,9 @@ import { type Href = ComponentProps['href']; -function isRelativePathname(pathname?: string | null) { - return pathname != null && !pathname.startsWith('/'); -} - function isRelativeHref(href: Href) { const pathname = typeof href === 'object' ? href.pathname : href; - return isRelativePathname(pathname); + return pathname != null && !pathname.startsWith('/'); } function isLocalHref(href: Href) { From 08675987afb075ca0cf978ac09a40b414de3bb45 Mon Sep 17 00:00:00 2001 From: Jan Amann Date: Thu, 26 Sep 2024 14:39:34 +0200 Subject: [PATCH 42/62] size limit --- packages/next-intl/.size-limit.ts | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/packages/next-intl/.size-limit.ts b/packages/next-intl/.size-limit.ts index 8cb1cb944..f6d16dfcb 100644 --- a/packages/next-intl/.size-limit.ts +++ b/packages/next-intl/.size-limit.ts @@ -15,37 +15,37 @@ const config: SizeLimitConfig = [ name: 'import {createSharedPathnamesNavigation} from \'next-intl/navigation\' (react-client)', path: 'dist/production/navigation.react-client.js', import: '{createSharedPathnamesNavigation}', - limit: '3.155 KB' + limit: '3.825 KB' }, { name: 'import {createLocalizedPathnamesNavigation} from \'next-intl/navigation\' (react-client)', path: 'dist/production/navigation.react-client.js', import: '{createLocalizedPathnamesNavigation}', - limit: '3.155 KB' + limit: '3.825 KB' }, { name: 'import {createNavigation} from \'next-intl/navigation\' (react-client)', path: 'dist/production/navigation.react-client.js', import: '{createNavigation}', - limit: '3.155 KB' + limit: '3.825 KB' }, { name: 'import {createSharedPathnamesNavigation} from \'next-intl/navigation\' (react-server)', path: 'dist/production/navigation.react-server.js', import: '{createSharedPathnamesNavigation}', - limit: '15.845 KB' + limit: '16.425 KB' }, { name: 'import {createLocalizedPathnamesNavigation} from \'next-intl/navigation\' (react-server)', path: 'dist/production/navigation.react-server.js', import: '{createLocalizedPathnamesNavigation}', - limit: '15.845 KB' + limit: '16.475 KB' }, { name: 'import {createNavigation} from \'next-intl/navigation\' (react-server)', path: 'dist/production/navigation.react-server.js', import: '{createNavigation}', - limit: '15.91 KB' + limit: '16.425 KB' }, { name: 'import * from \'next-intl/server\' (react-client)', @@ -60,7 +60,7 @@ const config: SizeLimitConfig = [ { 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\'', From 41f1b2e5e4a69301d7d13bca942c17507d78b766 Mon Sep 17 00:00:00 2001 From: Jan Amann Date: Thu, 26 Sep 2024 16:25:05 +0200 Subject: [PATCH 43/62] fix types for useRouter --- .../src/navigation/createNavigation.test.tsx | 8 ++- .../react-client/createNavigation.test.tsx | 62 ++++++++++++++++++- .../react-client/createNavigation.tsx | 2 +- 3 files changed, 69 insertions(+), 3 deletions(-) diff --git a/packages/next-intl/src/navigation/createNavigation.test.tsx b/packages/next-intl/src/navigation/createNavigation.test.tsx index b7923a493..683cb6da2 100644 --- a/packages/next-intl/src/navigation/createNavigation.test.tsx +++ b/packages/next-intl/src/navigation/createNavigation.test.tsx @@ -475,12 +475,18 @@ describe.each([ ).toBe('/de/neuigkeiten/launch-party-3'); }); - it('handles relative links', () => { + it('handles relative pathnames', () => { // @ts-expect-error -- Validation is still on const markup = renderToString(Test); expect(markup).toContain('href="test"'); }); + it('handles unknown pathnames', () => { + // @ts-expect-error -- Validation is still on + const markup = renderToString(Test); + expect(markup).toContain('href="/en/test"'); + }); + it('handles external links correctly', () => { const markup = renderToString( // @ts-expect-error -- Validation is still on diff --git a/packages/next-intl/src/navigation/react-client/createNavigation.test.tsx b/packages/next-intl/src/navigation/react-client/createNavigation.test.tsx index 5e6c5fe9a..9cc237c58 100644 --- a/packages/next-intl/src/navigation/react-client/createNavigation.test.tsx +++ b/packages/next-intl/src/navigation/react-client/createNavigation.test.tsx @@ -176,6 +176,13 @@ describe("localePrefix: 'always'", () => { }); }); + it('handles search params', () => { + invokeRouter((router) => router[method]('/test?foo=bar')); + expect(useNextRouter()[method]).toHaveBeenCalledWith( + '/en/test?foo=bar' + ); + }); + it('passes through absolute urls', () => { invokeRouter((router) => router[method]('https://example.com')); expect(useNextRouter()[method]).toHaveBeenCalledWith( @@ -259,13 +266,66 @@ describe("localePrefix: 'always', with `basePath`", () => { }); describe("localePrefix: 'always', with `pathnames`", () => { - const {usePathname} = createNavigation({ + const {usePathname, useRouter} = createNavigation({ locales, defaultLocale, localePrefix: 'always', pathnames }); + describe('useRouter', () => { + const invokeRouter = getInvokeRouter(useRouter); + + describe.each(['push', 'replace'] as const)('`%s`', (method) => { + it('localizes a pathname for the default locale', () => { + invokeRouter((router) => router[method]('/about')); + expect(useNextRouter()[method]).toHaveBeenCalledWith('/en/about'); + }); + + it('localizes a pathname for a secondary locale', () => { + invokeRouter((router) => router[method]('/about', {locale: 'de'})); + expect(useNextRouter()[method]).toHaveBeenCalledWith('/de/ueber-uns'); + }); + + it('handles pathname params', () => { + invokeRouter((router) => + router[method]({ + pathname: '/news/[articleSlug]-[articleId]', + params: { + articleSlug: 'launch-party', + articleId: '3' + } + }) + ); + expect(useNextRouter()[method]).toHaveBeenCalledWith( + '/en/news/launch-party-3' + ); + }); + + it('handles search params', () => { + invokeRouter((router) => + router[method]({ + pathname: '/about', + query: { + foo: 'bar' + } + }) + ); + expect(useNextRouter()[method]).toHaveBeenCalledWith( + '/en/about?foo=bar' + ); + }); + + it('disallows unknown pathnames', () => { + // @ts-expect-error -- Unknown pathname + invokeRouter((router) => router[method]('/unknown')); + + // Still works + expect(useNextRouter()[method]).toHaveBeenCalledWith('/en/unknown'); + }); + }); + }); + describe('usePathname', () => { it('returns a typed pathname', () => { type Return = ReturnType; diff --git a/packages/next-intl/src/navigation/react-client/createNavigation.tsx b/packages/next-intl/src/navigation/react-client/createNavigation.tsx index f348ae30c..ed95ac13a 100644 --- a/packages/next-intl/src/navigation/react-client/createNavigation.tsx +++ b/packages/next-intl/src/navigation/react-client/createNavigation.tsx @@ -98,7 +98,7 @@ export default function createNavigation< Fn extends (href: string, options?: Options) => void >(fn: Fn) { return function handler( - href: string, + href: Parameters[0]['href'], options?: Partial & {locale?: Locale} ): void { const {locale: nextLocale, ...rest} = options || {}; From 0325aa2fcec810da0b725e3cbf833385715ec7bb Mon Sep 17 00:00:00 2001 From: Jan Amann Date: Thu, 26 Sep 2024 17:38:36 +0200 Subject: [PATCH 44/62] docs --- .../app-router/with-i18n-routing.mdx | 4 +- docs/pages/docs/routing.mdx | 56 ++- docs/pages/docs/routing/middleware.mdx | 2 + docs/pages/docs/routing/navigation.mdx | 402 ++++++------------ docs/pages/docs/usage/messages.mdx | 2 +- .../src/navigation/createNavigation.test.tsx | 8 +- .../react-client/createNavigation.test.tsx | 14 +- .../next-intl/src/navigation/shared/utils.tsx | 5 +- 8 files changed, 182 insertions(+), 311 deletions(-) diff --git a/docs/pages/docs/getting-started/app-router/with-i18n-routing.mdx b/docs/pages/docs/getting-started/app-router/with-i18n-routing.mdx index cfc8ba3cb..633ea2bea 100644 --- a/docs/pages/docs/getting-started/app-router/with-i18n-routing.mdx +++ b/docs/pages/docs/getting-started/app-router/with-i18n-routing.mdx @@ -107,7 +107,7 @@ To share the configuration between these two places, we'll set up `routing.ts`: ```ts filename="src/i18n/routing.ts" import {defineRouting} from 'next-intl/routing'; -import {createSharedPathnamesNavigation} from 'next-intl/navigation'; +import {createNavigation} from 'next-intl/navigation'; export const routing = defineRouting({ // A list of all locales that are supported @@ -120,7 +120,7 @@ export const routing = defineRouting({ // Lightweight wrappers around Next.js' navigation APIs // that will consider the routing configuration export const {Link, redirect, usePathname, useRouter} = - createSharedPathnamesNavigation(routing); + createNavigation(routing); ``` Depending on your requirements, you may wish to customize your routing configuration later—but let's finish with the setup first. diff --git a/docs/pages/docs/routing.mdx b/docs/pages/docs/routing.mdx index d4054d941..232dd95e9 100644 --- a/docs/pages/docs/routing.mdx +++ b/docs/pages/docs/routing.mdx @@ -41,7 +41,7 @@ Depending on your routing needs, you may wish to consider further settings. In case you're building an app where locales can be added and removed at runtime, you can provide the routing configuration for the middleware [dynamically per request](/docs/routing/middleware#composing-other-middlewares). -To create the corresponding navigation APIs, you can [omit the `locales` argument](/docs/routing/navigation#locales-unknown) from `createSharedPathnamesNavigation` in this case. +To create the corresponding navigation APIs, you can [omit the `locales` argument](/docs/routing/navigation#locales-unknown) from `createNavigation` in this case. @@ -84,10 +84,7 @@ export const routing = defineRouting({ In this case, requests where the locale prefix matches the default locale will be redirected (e.g. `/en/about` to `/about`). This will affect both prefix-based as well as domain-based routing. -**Note that:** - -1. If you use this strategy, you should make sure that your middleware matcher detects [unprefixed pathnames](/docs/routing/middleware#matcher-no-prefix). -2. If you use [the `Link` component](/docs/routing/navigation#link), the initial render will point to the prefixed version but will be patched immediately on the client once the component detects that the default locale has rendered. The prefixed version is still valid, but SEO tools might report a hint that the link points to a redirect. +**Note that:** If you use this strategy, you should make sure that your middleware matcher detects [unprefixed pathnames](/docs/routing/middleware#matcher-no-prefix) for the routing to work as expected. #### Never use a locale prefix [#locale-prefix-never] @@ -95,8 +92,8 @@ If you'd like to provide a locale to `next-intl`, e.g. based on user settings, y However, you can also configure the middleware to never show a locale prefix in the URL, which can be helpful in the following cases: -1. You're using [domain-based routing](#domains) and you support only a single locale per domain -2. You're using a cookie to determine the locale but would like to enable static rendering +1. You want to use [domain-based routing](#domains) and have only one locale per domain +2. You want to use a cookie to determine locale while enabling static rendering ```tsx filename="routing.ts" {5} import {defineRouting} from 'next-intl/routing'; @@ -153,8 +150,8 @@ function Component() { // Assuming the locale is 'en-US' const locale = useLocale(); - // Returns 'US' - new Intl.Locale(locale).region; + // Extracts the "US" region + const {region} = new Intl.Locale(locale); } ``` @@ -222,13 +219,6 @@ export const routing = defineRouting({ Localized pathnames map to a single internal pathname that is created via the file-system based routing in Next.js. In the example above, `/de/ueber-uns` will be handled by the page at `/[locale]/about/page.tsx`. - - If you're using localized pathnames, you should use - `createLocalizedPathnamesNavigation` instead of - `createSharedPathnamesNavigation` for your [navigation - APIs](/docs/routing/navigation). - -
How can I revalidate localized pathnames? @@ -373,3 +363,37 @@ export const routing = defineRouting({ Learn more about this in the [locale detection for domain-based routing](/docs/routing/middleware#location-detection-domain) docs.
+ +
+Special case: Using `domains` with `localePrefix: 'as-needed'` + +Since domains can have different default locales, this combination requires some tradeoffs that apply to the [navigation APIs](/docs/routing/navigation) in order for `next-intl` to avoid reading the current host on the server side (which would prevent the usage of static rendering). + +1. [``](/docs/routing/navigation#link): This component will always render a locale prefix on the server side, even for the default locale of a given domain. However, during hydration on the client side, the prefix is potentially removed, if the default locale of the current domain is used. Note that the temporarily prefixed pathname will always be valid, however the middleware will potentially clean up a superfluous prefix via a redirect if the user clicks on a link before hydration. +2. [`redirect`](/docs/routing/navigation#redirect): When calling this function, a locale prefix is always added, regardless of the current locale and domain. However, similar to the handling with ``, the middleware will potentially clean up a superfluous prefix. +3. [`getPathname`](/docs/routing/navigation#getpathname): This function requires that a `domain` is passed as part of the arguments in order to avoid ambiguity. This can either be provided statically (e.g. when used in a sitemap), or read from a header like [`x-forwarded-host`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/X-Forwarded-Host). + +```tsx +import {getPathname} from '@/i18n/routing'; +import {headers} from 'next/headers'; + +// Case 1: Statically known domain +const domain = 'ca.example.com'; + +// Case 2: Read at runtime (dynamic rendering) +const domain = headers().get('x-forwarded-host'); + +// Assuming the current domain is `ca.example.com`, +// the returned pathname will be `/about` +const pathname = getPathname({ + href: '/about', + locale: 'en', + domain +}); +``` + +The tradeoff with `redirect` can be mitigated by implementing the redirect either in the middleware or via [`useRouter`](/docs/routing/navigation#usrouter) on the client side. The other ones are however necessary in order to retain the ability to render pages statically. + +If you need to avoid these tradeoffs, you can consider building the same app for each domain separately, while injecting diverging routing configuration via an environment variable. + +
diff --git a/docs/pages/docs/routing/middleware.mdx b/docs/pages/docs/routing/middleware.mdx index cea739428..4a1d011fd 100644 --- a/docs/pages/docs/routing/middleware.mdx +++ b/docs/pages/docs/routing/middleware.mdx @@ -14,6 +14,8 @@ The middleware receives a [`routing`](/docs/routing#define-routing) configuratio 2. Applying relevant redirects & rewrites 3. Providing [alternate links](#alternate-links) for search engines +**Example:** + ```tsx filename="middleware.ts" import createMiddleware from 'next-intl/middleware'; import {routing} from './i18n/routing'; diff --git a/docs/pages/docs/routing/navigation.mdx b/docs/pages/docs/routing/navigation.mdx index 34e7f722a..a77748a1b 100644 --- a/docs/pages/docs/routing/navigation.mdx +++ b/docs/pages/docs/routing/navigation.mdx @@ -11,88 +11,54 @@ import Details from 'components/Details'; `next-intl` provides lightweight wrappers around Next.js' navigation APIs like [``](https://nextjs.org/docs/app/api-reference/components/link) and [`useRouter`](https://nextjs.org/docs/app/api-reference/functions/use-router) that automatically handle the user locale and pathnames behind the scenes. -Depending on if you're using the [`pathnames`](/docs/routing#pathnames) setting, you can pick from one of these functions to create the corresponding navigation APIs: - -- `createSharedPathnamesNavigation`: Pathnames are shared across all locales (default) -- `createLocalizedPathnamesNavigation`: Pathnames are provided per locale (use with `pathnames`) - -These functions are typically called in a central module like [`src/i18n/routing.ts`](/docs/getting-started/app-router/with-i18n-routing#i18n-routing) in order to provide easy access to navigation APIs in your components and should receive a [`routing`](/docs/routing) configuration that is shared with the middleware. - - - +To create these APIs, you can call the `createNavigation` function with your `routing` configuration: ```tsx filename="routing.ts" -import {createSharedPathnamesNavigation} from 'next-intl/navigation'; +import {createNavigation} from 'next-intl/navigation'; import {defineRouting} from 'next-intl/routing'; -const routing = defineRouting(/* ... */); +export const routing = defineRouting(/* ... */); export const {Link, redirect, usePathname, useRouter} = - createSharedPathnamesNavigation(routing); + createNavigation(routing); ``` -
-What if the locales aren't known at build time? - -In case you're building an app where locales can be added and removed at runtime, `createSharedPathnamesNavigation` can be called without the `locales` argument, therefore allowing any string that is encountered at runtime to be a valid locale. - -In this case, you'd not use the `defineRouting` function. +This function is typically called in a central module like [`src/i18n/routing.ts`](/docs/getting-started/app-router/with-i18n-routing#i18n-routing) in order to provide easy access to navigation APIs in your components. -```tsx filename="routing.ts" -import {createSharedPathnamesNavigation} from 'next-intl/navigation'; - -export const {Link, redirect, usePathname, useRouter} = - createSharedPathnamesNavigation({ - // ... potentially other routing - // config, but no `locales` ... - }); -``` +
+How can I ensure consistent usage of navigation APIs? -Note however that the `locales` argument for the middleware is mandatory. However, you can provide the routing configuration for the middleware [dynamically per request](/docs/routing/middleware#composing-other-middlewares). +To avoid importing APIs like `` directly from Next.js by accident, you can consider [linting](/docs/workflows/linting#consistent-usage-of-navigation-apis) for the consistent usage of internationalized navigation APIs.
- - +
+What if the locales aren't known at build time? + +In case you're building an app where locales can be added and removed at runtime, `createNavigation` can be called without the `locales` argument, therefore allowing any string that is encountered at runtime to be a valid locale. In this case, you'd not use the [`defineRouting`](/docs/routing#define-routing) function. ```tsx filename="routing.ts" -import {createLocalizedPathnamesNavigation} from 'next-intl/navigation'; -import {defineRouting} from 'next-intl/routing'; +import {createNavigation} from 'next-intl/navigation'; -const routing = defineRouting({ - // ... - pathnames: { - // ... - } +export const {Link, redirect, usePathname, useRouter} = createNavigation({ + // ... potentially other routing + // config, but no `locales` ... }); - -export const {Link, redirect, usePathname, useRouter, getPathname} = - createLocalizedPathnamesNavigation(routing); ``` - - Have a look at the [App Router example](/examples#app-router) to explore a - working implementation of localized pathnames. - - - - - -
-How can I ensure consistent usage of navigation APIs? - -To ensure consistent usage in your app, you can consider [linting for usage of these APIs](/docs/workflows/linting#consistent-usage-of-navigation-apis). +Note however that the `locales` argument for the middleware is still mandatory. If you need to fetch the available locales at runtime, you can provide the routing configuration for the middleware [dynamically per request](/docs/routing/middleware#composing-other-middlewares).
## APIs -### `Link` +The created navigation APIs are thin wrappers around the equivalents from Next.js and mostly adhere to the same function signatures. Your routing configuration and the user's locale are automatically incorporated. + +If you're using the [`pathnames`](/docs/routing#pathnames) setting in your routing configuration, the internal pathnames that are accepted for `href` arguments will be strictly typed and localized to the given locale. Additionally, dynamic params will need to be passed separately from the template (see below). -This component wraps [`next/link`](https://nextjs.org/docs/app/api-reference/components/link) and automatically incorporates your routing strategy. +### `Link` - - +This component wraps [`next/link`](https://nextjs.org/docs/app/api-reference/components/link) and localizes the pathname as necessary. ```tsx import {Link} from '@/i18n/routing'; @@ -100,15 +66,30 @@ import {Link} from '@/i18n/routing'; // When the user is on `/en`, the link will point to `/en/about` About +// Search params can be added via `query` +Users + // You can override the `locale` to switch to another language Switch to German - -// Dynamic params need to be interpolated into the pathname -Susan ``` If you're providing the `locale` prop, the `hreflang` attribute will be set accordingly on the anchor tag. +Depending on if you're using the [`pathnames`](/docs/routing#pathnames) setting, dynamic params can either be passed as: + +```tsx +// 1. A final string (when not using `pathnames`) +Susan + +// 2. An object (when using `pathnames`) + + Susan + +``` + - - -When using [localized pathnames](/docs/routing#pathnames), the `href` prop corresponds to an internal pathname, but will be mapped to a locale-specific pathname. - -```tsx -import {Link} from '@/i18n/routing'; - -// When the user is on `/de`, the link will point to `/de/ueber-uns` -About - -// You can override the `locale` to switch to another language -Switch to English - -// Dynamic params can be passed via the object form - - Susan - - -// Catch-all params can be passed as arrays - - T-Shirts - - -// Search params can be added via `query` -Users -``` - - - - - - +
+Can I use a different `localePrefix` setting per domain? + +Since such a configuration would require reading the domain at runtime, this would prevent the ability to render pages statically. Due to this, `next-intl` doesn't support this configuration out of the box. + +However, you can still achieve this by building the app for each domain separately, while injecting diverging routing configuration via an environment variable. + +**Example:** + +```tsx filename="routing.ts" +import {defineRouting} from 'next-intl/routing'; + +export const routing = defineRouting({ + locales: ['en', 'fr'], + defaultLocale: 'en', + localePrefix: + process.env.VERCEL_URL === 'us.example.com' ? 'never' : 'always', + domains: [ + { + domain: 'us.example.com', + defaultLocale: 'en', + locales: ['en'] + }, + { + domain: 'ca.example.com', + defaultLocale: 'en' + } + ] +}); +``` + +
+
Special case: Using `domains` with `localePrefix: 'as-needed'` From 9e23771e8bb1537f145aa1391fdae39677454653 Mon Sep 17 00:00:00 2001 From: Jan Amann Date: Thu, 26 Sep 2024 21:41:25 +0200 Subject: [PATCH 46/62] legacy api docs --- docs/pages/docs/routing/navigation.mdx | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/docs/pages/docs/routing/navigation.mdx b/docs/pages/docs/routing/navigation.mdx index a77748a1b..f8a13a786 100644 --- a/docs/pages/docs/routing/navigation.mdx +++ b/docs/pages/docs/routing/navigation.mdx @@ -342,3 +342,13 @@ const pathname = getPathname({ }); ``` +## Legacy APIs + +`next-intl@3.0.0` brought the first release of the navigation APIs with these functions: + +- `createSharedPathnamesNavigation` +- `createLocalizedPathnamesNavigation` + +As part of `next-intl@3.21.0`, these functions have been replaced by a single `createNavigation` function, which unifies the API for both use cases and also fixes a few quirks in the previous APIs. Going forward, `createNavigation` is recommended and the previous functions will be deprecated in an upcoming release. + +While `createNavigation` is mostly API-compatible, there are some minor differences that should be noted. Please refer to [PR #1316](https://github.com/amannn/next-intl/pull/1316) for full details. From fad49c61e1a7c96cb373ecf042a740d2f060ff89 Mon Sep 17 00:00:00 2001 From: Jan Amann Date: Thu, 26 Sep 2024 22:53:51 +0200 Subject: [PATCH 47/62] wording --- docs/pages/docs/routing/navigation.mdx | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/docs/pages/docs/routing/navigation.mdx b/docs/pages/docs/routing/navigation.mdx index f8a13a786..970d4b42c 100644 --- a/docs/pages/docs/routing/navigation.mdx +++ b/docs/pages/docs/routing/navigation.mdx @@ -25,13 +25,6 @@ export const {Link, redirect, usePathname, useRouter} = This function is typically called in a central module like [`src/i18n/routing.ts`](/docs/getting-started/app-router/with-i18n-routing#i18n-routing) in order to provide easy access to navigation APIs in your components. -
-How can I ensure consistent usage of navigation APIs? - -To avoid importing APIs like `` directly from Next.js by accident, you can consider [linting](/docs/workflows/linting#consistent-usage-of-navigation-apis) for the consistent usage of internationalized navigation APIs. - -
-
What if the locales aren't known at build time? @@ -54,7 +47,14 @@ Note however that the `locales` argument for the middleware is still mandatory. The created navigation APIs are thin wrappers around the equivalents from Next.js and mostly adhere to the same function signatures. Your routing configuration and the user's locale are automatically incorporated. -If you're using the [`pathnames`](/docs/routing#pathnames) setting in your routing configuration, the internal pathnames that are accepted for `href` arguments will be strictly typed and localized to the given locale. Additionally, dynamic params will need to be passed separately from the template (see below). +If you're using the [`pathnames`](/docs/routing#pathnames) setting in your routing configuration, the internal pathnames that are accepted for `href` arguments will be strictly typed and localized to the given locale. + +
+How can I ensure consistent usage of navigation APIs? + +To avoid importing APIs like `` directly from Next.js by accident, you can consider [linting](/docs/workflows/linting#consistent-usage-of-navigation-apis) for the consistent usage of internationalized navigation APIs. + +
### `Link` @@ -257,7 +257,7 @@ import {usePathname} from '@/i18n/routing'; const pathname = usePathname(); ``` -Note that if you're using the [`pathnames`](/docs/routing#pathnames) setting, the returned pathname will correspond to an internal pathname, potentially also without dynamic params being resolved. +Note that if you're using the [`pathnames`](/docs/routing#pathnames) setting, the returned pathname will correspond to an internal pathname template (dynamic params will not be replaced by their values). ```tsx // When the user is on `/de/ueber-uns`, this will be `/about` From fad59bf789c4e104df5707b05f21a0ef57049601 Mon Sep 17 00:00:00 2001 From: Jan Amann Date: Fri, 27 Sep 2024 12:09:09 +0200 Subject: [PATCH 48/62] Mandatory locale for `getPathname` and allow async locale from headers in navigation APIs --- docs/pages/docs/routing/navigation.mdx | 17 ++- .../src/navigation/createNavigation.test.tsx | 129 +++++++++++------- .../shared/createSharedNavigationFns.tsx | 63 +++++---- .../next-intl/src/react-server/index.test.tsx | 5 +- .../src/react-server/useTranslations.test.tsx | 4 - 5 files changed, 128 insertions(+), 90 deletions(-) diff --git a/docs/pages/docs/routing/navigation.mdx b/docs/pages/docs/routing/navigation.mdx index 970d4b42c..b60d55206 100644 --- a/docs/pages/docs/routing/navigation.mdx +++ b/docs/pages/docs/routing/navigation.mdx @@ -272,26 +272,31 @@ const pathname = usePathname(); If you want to interrupt the render and redirect to another page, you can invoke the `redirect` function. This wraps [the `redirect` function from Next.js](https://nextjs.org/docs/app/api-reference/functions/redirect) and localizes the pathname as necessary. +Note that a `locale` prop is always required, even if you're just passing [the current locale](/docs/usage/configuration#locale). + ```tsx import {redirect} from '@/i18n/routing'; -// When the user is on `/en`, this will be `/en/login` -redirect('/login'); +// Redirects to `/en/login` +redirect({href: '/login', locale: 'en'}); // Search params can be added via an object -redirect({pathname: '/users', query: {sortBy: 'name'}}); +redirect({href: '/users', query: {sortBy: 'name'}, locale: 'en'}); ``` Depending on if you're using the pathnames setting, dynamic params can either be passed as: ```tsx // 1. A final string (when not using `pathnames`) -redirect('/users/12'); +redirect({href: '/users/12', locale: 'en'}); // 2. An object (when using `pathnames`) redirect({ - pathname: '/users/[userId]', - params: {userId: '5'} + href: { + pathname: '/users/[userId]', + params: {userId: '5'} + }, + locale: 'en' }); ``` diff --git a/packages/next-intl/src/navigation/createNavigation.test.tsx b/packages/next-intl/src/navigation/createNavigation.test.tsx index 09013bca8..21f8aea1d 100644 --- a/packages/next-intl/src/navigation/createNavigation.test.tsx +++ b/packages/next-intl/src/navigation/createNavigation.test.tsx @@ -23,18 +23,17 @@ vi.mock('next/navigation', async () => { permanentRedirect: vi.fn() }; }); -vi.mock('next-intl/config', () => ({ - default: async () => - ((await vi.importActual('../../src/server')) as any).getRequestConfig({ - locale: 'en' - }) -})); -vi.mock('../../src/server/react-server/RequestLocale', () => ({ - getRequestLocale: vi.fn(() => 'en') -})); +vi.mock('../../src/server/react-server/RequestLocale'); function mockCurrentLocale(locale: string) { - vi.mocked(getRequestLocale).mockImplementation(() => locale); + // Enable synchronous rendering without having to suspend + const localePromise = Promise.resolve(locale); + (localePromise as any).status = 'fulfilled'; + (localePromise as any).value = locale; + + // @ts-expect-error -- Async values are allowed + vi.mocked(getRequestLocale).mockImplementation(() => localePromise); + vi.mocked(nextUseParams<{locale: string}>).mockImplementation(() => ({ locale })); @@ -297,39 +296,46 @@ describe.each([ ['permanentRedirect', permanentRedirect, nextPermanentRedirect] ])('%s', (_, redirectFn, nextRedirectFn) => { it('can redirect for the default locale', () => { - runInRender(() => redirectFn('/')); + runInRender(() => redirectFn({href: '/', locale: 'en'})); expect(nextRedirectFn).toHaveBeenLastCalledWith('/en'); }); it('forwards a redirect type', () => { - runInRender(() => redirectFn('/', RedirectType.push)); + runInRender(() => + redirectFn({href: '/', locale: 'en'}, RedirectType.push) + ); expect(nextRedirectFn).toHaveBeenLastCalledWith( '/en', RedirectType.push ); }); - // There's nothing strictly against this, but there was no need for this so - // far. The API design is a bit tricky since Next.js uses the second argument - // for a plain `type` string. Should we support an object here? Also consider - // API symmetry with `router.push`. - it('can not redirect for a different locale', () => { - // @ts-expect-error - // eslint-disable-next-line no-unused-expressions - () => redirectFn('/about', {locale: 'de'}); + it('can redirect to a different locale', () => { + runInRender(() => redirectFn({href: '/about', locale: 'de'})); + expect(nextRedirectFn).toHaveBeenLastCalledWith('/de/about'); }); it('handles relative pathnames', () => { - runInRender(() => redirectFn('about')); + runInRender(() => redirectFn({href: 'about', locale: 'en'})); expect(nextRedirectFn).toHaveBeenLastCalledWith('about'); }); it('handles search params', () => { runInRender(() => - redirectFn({pathname: '/about', query: {foo: 'bar'}}) + redirectFn({ + href: {pathname: '/about', query: {foo: 'bar'}}, + locale: 'en' + }) ); expect(nextRedirectFn).toHaveBeenLastCalledWith('/en/about?foo=bar'); }); + + it('requires a locale', () => { + // @ts-expect-error -- Object expected + redirectFn('/'); + // @ts-expect-error -- Missing locale + redirectFn({pathname: '/about'}); + }); }); }); @@ -379,8 +385,8 @@ describe.each([ ['redirect', redirect, nextRedirect], ['permanentRedirect', permanentRedirect, nextPermanentRedirect] ])('%s', (_, redirectFn, nextRedirectFn) => { - it('can redirect for the current locale', () => { - runInRender(() => redirectFn('/')); + it('can redirect for the default locale', () => { + runInRender(() => redirectFn({href: '/', locale: 'en'})); expect(nextRedirectFn).toHaveBeenLastCalledWith('/en'); }); }); @@ -550,19 +556,22 @@ describe.each([ ['permanentRedirect', permanentRedirect, nextPermanentRedirect] ])('%s', (_, redirectFn, nextRedirectFn) => { it('can redirect for the default locale', () => { - runInRender(() => redirectFn('/')); + runInRender(() => redirectFn({href: '/', locale: 'en'})); expect(nextRedirectFn).toHaveBeenLastCalledWith('/en'); }); it('can redirect with params and search params', () => { runInRender(() => redirectFn({ - pathname: '/news/[articleSlug]-[articleId]', - params: { - articleId: 3, - articleSlug: 'launch-party' + href: { + pathname: '/news/[articleSlug]-[articleId]', + params: { + articleId: 3, + articleSlug: 'launch-party' + }, + query: {foo: 'bar'} }, - query: {foo: 'bar'} + locale: 'en' }) ); expect(nextRedirectFn).toHaveBeenLastCalledWith( @@ -572,13 +581,15 @@ describe.each([ it('can not be called with an arbitrary pathname', () => { // @ts-expect-error -- Unknown pathname - runInRender(() => redirectFn('/unknown')); + runInRender(() => redirectFn({href: '/unknown', locale: 'en'})); // Works regardless expect(nextRedirectFn).toHaveBeenLastCalledWith('/en/unknown'); }); it('forwards a redirect type', () => { - runInRender(() => redirectFn('/', RedirectType.push)); + runInRender(() => + redirectFn({href: '/', locale: 'en'}, RedirectType.push) + ); expect(nextRedirectFn).toHaveBeenLastCalledWith( '/en', RedirectType.push @@ -587,7 +598,7 @@ describe.each([ it('can handle relative pathnames', () => { // @ts-expect-error -- Validation is still on - runInRender(() => redirectFn('about')); + runInRender(() => redirectFn({href: 'about', locale: 'en'})); expect(nextRedirectFn).toHaveBeenLastCalledWith('about'); }); }); @@ -701,18 +712,19 @@ describe.each([ ['permanentRedirect', permanentRedirect, nextPermanentRedirect] ])('%s', (_, redirectFn, nextRedirectFn) => { it('does not add a prefix when redirecting within the default locale', () => { - runInRender(() => redirectFn('/')); + runInRender(() => redirectFn({href: '/', locale: 'en'})); expect(nextRedirectFn).toHaveBeenLastCalledWith('/'); }); - it('adds a prefix when currently on a secondary locale', () => { - mockCurrentLocale('de'); - runInRender(() => redirectFn('/')); + it('adds a prefix for a secondary locale', () => { + runInRender(() => redirectFn({href: '/', locale: 'de'})); expect(nextRedirectFn).toHaveBeenLastCalledWith('/de'); }); it('forwards a redirect type', () => { - runInRender(() => redirectFn('/', RedirectType.push)); + runInRender(() => + redirectFn({href: '/', locale: 'en'}, RedirectType.push) + ); expect(nextRedirectFn).toHaveBeenLastCalledWith('/', RedirectType.push); }); }); @@ -767,13 +779,12 @@ describe.each([ ['permanentRedirect', permanentRedirect, nextPermanentRedirect] ])('%s', (_, redirectFn, nextRedirectFn) => { it('adds a prefix for the default locale', () => { - runInRender(() => redirectFn('/')); + runInRender(() => redirectFn({href: '/', locale: 'en'})); expect(nextRedirectFn).toHaveBeenLastCalledWith('/us/en'); }); it('adds a prefix for a secondary locale', () => { - mockCurrentLocale('de'); - runInRender(() => redirectFn('/about')); + runInRender(() => redirectFn({href: '/about', locale: 'de'})); expect(nextRedirectFn).toHaveBeenLastCalledWith('/eu/de/about'); }); }); @@ -816,7 +827,19 @@ describe.each([ ['permanentRedirect', permanentRedirect, nextPermanentRedirect] ])('%s', (_, redirectFn, nextRedirectFn) => { it('adds a prefix for the default locale', () => { - runInRender(() => redirectFn('/')); + runInRender(() => redirectFn({href: '/', locale: 'en'})); + expect(nextRedirectFn).toHaveBeenLastCalledWith('/en'); + }); + + it('does not allow passing a domain', () => { + runInRender(() => + redirectFn({ + href: '/', + locale: 'en', + // @ts-expect-error -- Domain is not allowed + domain: 'example.com' + }) + ); expect(nextRedirectFn).toHaveBeenLastCalledWith('/en'); }); }); @@ -900,13 +923,19 @@ describe.each([ ])('%s', (_, redirectFn, nextRedirectFn) => { it('adds a prefix even for the default locale', () => { // (see comment in source for reasoning) - runInRender(() => redirectFn('/')); + runInRender(() => redirectFn({href: '/', locale: 'en'})); expect(nextRedirectFn).toHaveBeenLastCalledWith('/en'); }); - it('adds a prefix when currently on a secondary locale', () => { - mockCurrentLocale('de'); - runInRender(() => redirectFn('/')); + it('does not add a prefix when domain is provided for the default locale', () => { + runInRender(() => + redirectFn({href: '/', locale: 'en', domain: 'example.com'}) + ); + expect(nextRedirectFn).toHaveBeenLastCalledWith('/'); + }); + + it('adds a prefix for a secondary locale', () => { + runInRender(() => redirectFn({href: '/', locale: 'de'})); expect(nextRedirectFn).toHaveBeenLastCalledWith('/de'); }); }); @@ -973,12 +1002,14 @@ describe.each([ ['permanentRedirect', permanentRedirect, nextPermanentRedirect] ])('%s', (_, redirectFn, nextRedirectFn) => { it('can redirect for the default locale', () => { - runInRender(() => redirectFn('/')); + runInRender(() => redirectFn({href: '/', locale: 'en'})); expect(nextRedirectFn).toHaveBeenLastCalledWith('/'); }); it('forwards a redirect type', () => { - runInRender(() => redirectFn('/', RedirectType.push)); + runInRender(() => + redirectFn({href: '/', locale: 'en'}, RedirectType.push) + ); expect(nextRedirectFn).toHaveBeenLastCalledWith('/', RedirectType.push); }); }); @@ -1034,7 +1065,7 @@ describe.each([ ['permanentRedirect', permanentRedirect, nextPermanentRedirect] ])('%s', (_, redirectFn, nextRedirectFn) => { it('adds no prefix for the default locale', () => { - runInRender(() => redirectFn('/')); + runInRender(() => redirectFn({href: '/', locale: 'en'})); expect(nextRedirectFn).toHaveBeenLastCalledWith('/'); }); }); diff --git a/packages/next-intl/src/navigation/shared/createSharedNavigationFns.tsx b/packages/next-intl/src/navigation/shared/createSharedNavigationFns.tsx index f3b7e47f9..e5250d530 100644 --- a/packages/next-intl/src/navigation/shared/createSharedNavigationFns.tsx +++ b/packages/next-intl/src/navigation/shared/createSharedNavigationFns.tsx @@ -2,7 +2,7 @@ import { permanentRedirect as nextPermanentRedirect, redirect as nextRedirect } from 'next/navigation'; -import React, {ComponentProps} from 'react'; +import React, {ComponentProps, use} from 'react'; import { receiveRoutingConfig, RoutingConfigLocalizedNavigation, @@ -29,6 +29,10 @@ import { validateReceivedConfig } from './utils'; +type PromiseOrValue = Type | Promise; +type UnwrapPromiseOrValue = + Type extends Promise ? Value : Type; + /** * Shared implementations for `react-server` and `react-client` */ @@ -38,7 +42,9 @@ export default function createSharedNavigationFns< const AppLocalePrefixMode extends LocalePrefixMode = 'always', const AppDomains extends DomainsConfig = never >( - getLocale: () => AppLocales extends never ? string : AppLocales[number], + getLocale: () => PromiseOrValue< + AppLocales extends never ? string : AppLocales[number] + >, routing?: [AppPathnames] extends [never] ? | RoutingConfigSharedNavigation< @@ -54,7 +60,7 @@ export default function createSharedNavigationFns< AppDomains > ) { - type Locale = ReturnType; + type Locale = UnwrapPromiseOrValue>; const config = receiveRoutingConfig(routing || {}); if (process.env.NODE_ENV !== 'production') { @@ -105,7 +111,12 @@ export default function createSharedNavigationFns< // @ts-expect-error -- This is ok const isLocalizable = isLocalizableHref(href); - const curLocale = getLocale(); + const localePromiseOrValue = getLocale(); + const curLocale = + localePromiseOrValue instanceof Promise + ? use(localePromiseOrValue) + : localePromiseOrValue; + const finalPathname = isLocalizable ? getPathname( // @ts-expect-error -- This is ok @@ -157,6 +168,20 @@ export default function createSharedNavigationFns< ); } + type DomainConfigForAsNeeded = typeof routing extends undefined + ? {} + : AppLocalePrefixMode extends 'as-needed' + ? [AppDomains] extends [never] + ? {} + : { + /** + * In case you're using `localePrefix: 'as-needed'` in combination with `domains`, the `defaultLocale` can differ by domain and therefore the locales that need to be prefixed can differ as well. For this particular case, this parameter should be provided in order to compute the correct pathname. Note that the actual domain is not part of the result, but only the pathname is returned. + * @see https://next-intl-docs.vercel.app/docs/routing/navigation#getpathname + */ + domain: AppDomains[number]['domain']; + } + : {}; + function getPathname( args: { /** @see https://next-intl-docs.vercel.app/docs/routing/navigation#getpathname */ @@ -164,19 +189,7 @@ export default function createSharedNavigationFns< ? string | {pathname: string; query?: QueryParams} : HrefOrHrefWithParams; locale: Locale; - } & (typeof routing extends undefined - ? {} - : AppLocalePrefixMode extends 'as-needed' - ? [AppDomains] extends [never] - ? {} - : { - /** - * In case you're using `localePrefix: 'as-needed'` in combination with `domains`, the `defaultLocale` can differ by domain and therefore the locales that need to be prefixed can differ as well. For this particular case, this parameter should be provided in order to compute the correct pathname. Note that the actual domain is not part of the result, but only the pathname is returned. - * @see https://next-intl-docs.vercel.app/docs/routing/navigation#getpathname - */ - domain: AppDomains[number]['domain']; - } - : {}), + } & DomainConfigForAsNeeded, /** @private Removed in types returned below */ _forcePrefix?: boolean ) { @@ -217,18 +230,14 @@ export default function createSharedNavigationFns< ) { /** @see https://next-intl-docs.vercel.app/docs/routing/navigation#redirect */ return function redirectFn( - href: Parameters[0]['href'], - ...args: ParametersExceptFirst + args: Omit[0], 'domain'> & + Partial, + ...rest: ParametersExceptFirst ) { - const locale = getLocale(); - return fn( - getPathname( - // @ts-expect-error -- This is ok - {href, locale}, - forcePrefixSsr - ), - ...args + // @ts-expect-error -- We're forcing the prefix when no domain is provided + getPathname(args, args.domain ? undefined : forcePrefixSsr), + ...rest ); }; } diff --git a/packages/next-intl/src/react-server/index.test.tsx b/packages/next-intl/src/react-server/index.test.tsx index a358d784b..c8d435322 100644 --- a/packages/next-intl/src/react-server/index.test.tsx +++ b/packages/next-intl/src/react-server/index.test.tsx @@ -15,6 +15,7 @@ vi.mock('react'); vi.mock('../../src/server/react-server/createRequestConfig', () => ({ default: async () => ({ + locale: 'en', messages: { Component: { title: 'Title' @@ -26,10 +27,6 @@ vi.mock('../../src/server/react-server/createRequestConfig', () => ({ }) })); -vi.mock('../../src/server/react-server/RequestLocale', () => ({ - getRequestLocale: vi.fn(() => 'en') -})); - vi.mock('use-intl/core', async (importActual) => { const actual: any = await importActual(); return { diff --git a/packages/next-intl/src/react-server/useTranslations.test.tsx b/packages/next-intl/src/react-server/useTranslations.test.tsx index 93ee9aa24..ebf6cef67 100644 --- a/packages/next-intl/src/react-server/useTranslations.test.tsx +++ b/packages/next-intl/src/react-server/useTranslations.test.tsx @@ -20,10 +20,6 @@ vi.mock('../../src/server/react-server/createRequestConfig', () => ({ }) })); -vi.mock('../../src/server/react-server/RequestLocale', () => ({ - getRequestLocale: vi.fn(() => 'en') -})); - vi.mock('react'); vi.mock('use-intl/core', async (importActual) => { From d42c9261d5aa91314eca341ab249a5c7b551b70b Mon Sep 17 00:00:00 2001 From: Jan Amann Date: Fri, 27 Sep 2024 13:56:22 +0200 Subject: [PATCH 49/62] fix build --- docs/pages/docs/routing.mdx | 8 +++++--- .../src/app/[locale]/client/redirect/page.tsx | 6 ++++-- .../src/app/[locale]/redirect/page.tsx | 6 ++++-- 3 files changed, 13 insertions(+), 7 deletions(-) diff --git a/docs/pages/docs/routing.mdx b/docs/pages/docs/routing.mdx index 6f00f37a9..6115fe5d6 100644 --- a/docs/pages/docs/routing.mdx +++ b/docs/pages/docs/routing.mdx @@ -380,7 +380,9 @@ export const routing = defineRouting({ locales: ['en', 'fr'], defaultLocale: 'en', localePrefix: - process.env.VERCEL_URL === 'us.example.com' ? 'never' : 'always', + process.env.VERCEL_PROJECT_PRODUCTION_URL === 'us.example.com' + ? 'never' + : 'always', domains: [ { domain: 'us.example.com', @@ -425,8 +427,8 @@ const pathname = getPathname({ }); ``` -The tradeoff with `redirect` can be mitigated by implementing the redirect either in the middleware or via [`useRouter`](/docs/routing/navigation#usrouter) on the client side. The other ones are however necessary in order to retain the ability to render pages statically. +A `domain` can optionally also be passed to `redirect` in the same manner to ensure that a prefix is only added when necessary. Alternatively, you can also consider redirecting in the middleware or via [`useRouter`](/docs/routing/navigation#usrouter) on the client side. -If you need to avoid these tradeoffs, you can consider building the same app for each domain separately, while injecting diverging routing configuration via an environment variable. +If you need to avoid these tradeoffs, you can consider building the same app for each domain separately, while injecting diverging routing configuration via an [environment variable](#domains-localeprefix-individual).
diff --git a/examples/example-app-router-playground/src/app/[locale]/client/redirect/page.tsx b/examples/example-app-router-playground/src/app/[locale]/client/redirect/page.tsx index 0cfe7fff9..c46693355 100644 --- a/examples/example-app-router-playground/src/app/[locale]/client/redirect/page.tsx +++ b/examples/example-app-router-playground/src/app/[locale]/client/redirect/page.tsx @@ -1,7 +1,9 @@ 'use client'; -import {redirect} from '@/i18n/routing'; +import {useLocale} from 'next-intl'; +import {Locale, redirect} from '@/i18n/routing'; export default function ClientRedirectPage() { - redirect('/client'); + const locale = useLocale() as Locale; + redirect({href: '/client', locale}); } diff --git a/examples/example-app-router-playground/src/app/[locale]/redirect/page.tsx b/examples/example-app-router-playground/src/app/[locale]/redirect/page.tsx index 970f7ab2c..01f51e6d8 100644 --- a/examples/example-app-router-playground/src/app/[locale]/redirect/page.tsx +++ b/examples/example-app-router-playground/src/app/[locale]/redirect/page.tsx @@ -1,5 +1,7 @@ -import {redirect} from '@/i18n/routing'; +import {useLocale} from 'next-intl'; +import {Locale, redirect} from '@/i18n/routing'; export default function Redirect() { - redirect('/client'); + const locale = useLocale() as Locale; + redirect({href: '/client', locale}); } From f2c61e34f28599f1fc1d5dfa699f6757d42b5c43 Mon Sep 17 00:00:00 2001 From: Jan Amann Date: Fri, 27 Sep 2024 14:06:26 +0200 Subject: [PATCH 50/62] accept `locale` as string for navigation apis for better compatibility with `useLocale` --- .../src/app/[locale]/client/redirect/page.tsx | 4 +-- .../src/app/[locale]/redirect/page.tsx | 4 +-- .../src/navigation/createNavigation.test.tsx | 34 +++++++++---------- .../react-client/createNavigation.test.tsx | 8 ++++- .../react-client/createNavigation.tsx | 2 +- .../shared/createSharedNavigationFns.tsx | 4 +-- 6 files changed, 30 insertions(+), 26 deletions(-) diff --git a/examples/example-app-router-playground/src/app/[locale]/client/redirect/page.tsx b/examples/example-app-router-playground/src/app/[locale]/client/redirect/page.tsx index c46693355..c11a03496 100644 --- a/examples/example-app-router-playground/src/app/[locale]/client/redirect/page.tsx +++ b/examples/example-app-router-playground/src/app/[locale]/client/redirect/page.tsx @@ -1,9 +1,9 @@ 'use client'; import {useLocale} from 'next-intl'; -import {Locale, redirect} from '@/i18n/routing'; +import {redirect} from '@/i18n/routing'; export default function ClientRedirectPage() { - const locale = useLocale() as Locale; + const locale = useLocale(); redirect({href: '/client', locale}); } diff --git a/examples/example-app-router-playground/src/app/[locale]/redirect/page.tsx b/examples/example-app-router-playground/src/app/[locale]/redirect/page.tsx index 01f51e6d8..9adab80e8 100644 --- a/examples/example-app-router-playground/src/app/[locale]/redirect/page.tsx +++ b/examples/example-app-router-playground/src/app/[locale]/redirect/page.tsx @@ -1,7 +1,7 @@ import {useLocale} from 'next-intl'; -import {Locale, redirect} from '@/i18n/routing'; +import {redirect} from '@/i18n/routing'; export default function Redirect() { - const locale = useLocale() as Locale; + const locale = useLocale(); redirect({href: '/client', locale}); } diff --git a/packages/next-intl/src/navigation/createNavigation.test.tsx b/packages/next-intl/src/navigation/createNavigation.test.tsx index 21f8aea1d..e72d51c89 100644 --- a/packages/next-intl/src/navigation/createNavigation.test.tsx +++ b/packages/next-intl/src/navigation/createNavigation.test.tsx @@ -130,8 +130,14 @@ describe.each([ }); it('renders a prefix for a different locale', () => { + // Being able to accept a string and not only a strictly typed locale is + // important in order to be able to use a result from `useLocale()`. + // This is less relevant for `Link`, but this should be in sync across + // al navigation APIs (see https://github.com/amannn/next-intl/issues/1377) + const locale = 'de' as string; + const markup = renderToString( - + Über uns ); @@ -179,17 +185,6 @@ describe.each([ expect(markup).toContain('href="https://example.com/test"'); }); - it('does not allow to use unknown locales', () => { - const markup = renderToString( - // @ts-expect-error -- Unknown locale - - Unknown - - ); - // Still works - expect(markup).toContain('href="/zh/about"'); - }); - it('does not allow to receive params', () => { { it('can be called for the default locale', () => { - expect(getPathname({href: '/unknown', locale: 'en'})).toBe( - '/en/unknown' - ); + // Being able to accept a string and not only a strictly typed locale is + // important in order to be able to use a result from `useLocale()`. + // This is less relevant for `Link`, but this should be in sync across + // al navigation APIs (see https://github.com/amannn/next-intl/issues/1377) + const locale = 'en' as string; + + expect(getPathname({href: '/unknown', locale})).toBe('/en/unknown'); }); it('can be called for a secondary locale', () => { @@ -296,7 +295,8 @@ describe.each([ ['permanentRedirect', permanentRedirect, nextPermanentRedirect] ])('%s', (_, redirectFn, nextRedirectFn) => { it('can redirect for the default locale', () => { - runInRender(() => redirectFn({href: '/', locale: 'en'})); + const locale = 'en' as string; + runInRender(() => redirectFn({href: '/', locale})); expect(nextRedirectFn).toHaveBeenLastCalledWith('/en'); }); @@ -502,8 +502,6 @@ describe.each([ }); it('restricts invalid usage', () => { - // @ts-expect-error -- Unknown locale - ; // @ts-expect-error -- Unknown pathname ; // @ts-expect-error -- Missing params (this error is important when switching from shared pathnames to localized pathnames) diff --git a/packages/next-intl/src/navigation/react-client/createNavigation.test.tsx b/packages/next-intl/src/navigation/react-client/createNavigation.test.tsx index 8df535664..740776f2a 100644 --- a/packages/next-intl/src/navigation/react-client/createNavigation.test.tsx +++ b/packages/next-intl/src/navigation/react-client/createNavigation.test.tsx @@ -165,7 +165,13 @@ describe("localePrefix: 'always'", () => { }); it('prefixes with a secondary locale', () => { - invokeRouter((router) => router[method]('/about', {locale: 'de'})); + // Being able to accept a string and not only a strictly typed locale is + // important in order to be able to use a result from `useLocale()`. + // This is less relevant for `Link`, but this should be in sync across + // al navigation APIs (see https://github.com/amannn/next-intl/issues/1377) + const locale = 'de' as string; + + invokeRouter((router) => router[method]('/about', {locale})); expect(useNextRouter()[method]).toHaveBeenCalledWith('/de/about'); }); diff --git a/packages/next-intl/src/navigation/react-client/createNavigation.tsx b/packages/next-intl/src/navigation/react-client/createNavigation.tsx index ed95ac13a..7293e54b1 100644 --- a/packages/next-intl/src/navigation/react-client/createNavigation.tsx +++ b/packages/next-intl/src/navigation/react-client/createNavigation.tsx @@ -99,7 +99,7 @@ export default function createNavigation< >(fn: Fn) { return function handler( href: Parameters[0]['href'], - options?: Partial & {locale?: Locale} + options?: Partial & {locale?: string} ): void { const {locale: nextLocale, ...rest} = options || {}; diff --git a/packages/next-intl/src/navigation/shared/createSharedNavigationFns.tsx b/packages/next-intl/src/navigation/shared/createSharedNavigationFns.tsx index e5250d530..3213b6302 100644 --- a/packages/next-intl/src/navigation/shared/createSharedNavigationFns.tsx +++ b/packages/next-intl/src/navigation/shared/createSharedNavigationFns.tsx @@ -91,7 +91,7 @@ export default function createSharedNavigationFns< ? ComponentProps['href'] : HrefOrUrlObjectWithParams; /** @see https://next-intl-docs.vercel.app/docs/routing/navigation#link */ - locale?: Locale; + locale?: string; } >; function Link({ @@ -188,7 +188,7 @@ export default function createSharedNavigationFns< href: [AppPathnames] extends [never] ? string | {pathname: string; query?: QueryParams} : HrefOrHrefWithParams; - locale: Locale; + locale: string; } & DomainConfigForAsNeeded, /** @private Removed in types returned below */ _forcePrefix?: boolean From a86022455cd0dabb634506da30269becad5949c4 Mon Sep 17 00:00:00 2001 From: Jan Amann Date: Fri, 27 Sep 2024 14:10:39 +0200 Subject: [PATCH 51/62] bump sizes --- packages/next-intl/.size-limit.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/next-intl/.size-limit.ts b/packages/next-intl/.size-limit.ts index f6d16dfcb..b6a53fcb4 100644 --- a/packages/next-intl/.size-limit.ts +++ b/packages/next-intl/.size-limit.ts @@ -15,25 +15,25 @@ const config: SizeLimitConfig = [ name: 'import {createSharedPathnamesNavigation} from \'next-intl/navigation\' (react-client)', path: 'dist/production/navigation.react-client.js', import: '{createSharedPathnamesNavigation}', - limit: '3.825 KB' + limit: '3.855 KB' }, { name: 'import {createLocalizedPathnamesNavigation} from \'next-intl/navigation\' (react-client)', path: 'dist/production/navigation.react-client.js', import: '{createLocalizedPathnamesNavigation}', - limit: '3.825 KB' + limit: '3.855 KB' }, { name: 'import {createNavigation} from \'next-intl/navigation\' (react-client)', path: 'dist/production/navigation.react-client.js', import: '{createNavigation}', - limit: '3.825 KB' + limit: '3.865 KB' }, { name: 'import {createSharedPathnamesNavigation} from \'next-intl/navigation\' (react-server)', path: 'dist/production/navigation.react-server.js', import: '{createSharedPathnamesNavigation}', - limit: '16.425 KB' + limit: '16.455 KB' }, { name: 'import {createLocalizedPathnamesNavigation} from \'next-intl/navigation\' (react-server)', @@ -45,7 +45,7 @@ const config: SizeLimitConfig = [ name: 'import {createNavigation} from \'next-intl/navigation\' (react-server)', path: 'dist/production/navigation.react-server.js', import: '{createNavigation}', - limit: '16.425 KB' + limit: '16.445 KB' }, { name: 'import * from \'next-intl/server\' (react-client)', From 24795399455d036373d9e6b6847bcee49e7aae17 Mon Sep 17 00:00:00 2001 From: Jan Amann Date: Mon, 30 Sep 2024 09:39:54 +0200 Subject: [PATCH 52/62] docs wording --- docs/pages/docs/routing/navigation.mdx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/pages/docs/routing/navigation.mdx b/docs/pages/docs/routing/navigation.mdx index b60d55206..89043a117 100644 --- a/docs/pages/docs/routing/navigation.mdx +++ b/docs/pages/docs/routing/navigation.mdx @@ -189,7 +189,7 @@ const router = useRouter(); // When the user is on `/en`, the router will navigate to `/en/about` router.push('/about'); -// Search params can be added via an object +// Search params can be added via `query` router.push({ pathname: '/users', query: {sortBy: 'name'} @@ -280,7 +280,7 @@ import {redirect} from '@/i18n/routing'; // Redirects to `/en/login` redirect({href: '/login', locale: 'en'}); -// Search params can be added via an object +// Search params can be added via `query` redirect({href: '/users', query: {sortBy: 'name'}, locale: 'en'}); ``` @@ -318,7 +318,7 @@ const pathname = getPathname({ href: '/about' }); -// Search params can be added via an object +// Search params can be added via `query` const pathname = getPathname({ locale: 'en', href: { From 1cf2f970cdff309448e4f922ecf9b1b526c3371f Mon Sep 17 00:00:00 2001 From: Jan Amann Date: Mon, 30 Sep 2024 15:20:12 +0200 Subject: [PATCH 53/62] compatibility with radix primitives --- .../package.json | 1 + .../src/app/[locale]/page.tsx | 2 + .../src/components/DropdownMenu.tsx | 19 +++ .../tests/main.spec.ts | 8 + .../react-client/createNavigation.tsx | 4 +- .../src/navigation/shared/BaseLink.tsx | 26 +-- .../src/navigation/shared/LegacyBaseLink.tsx | 4 +- .../shared/createSharedNavigationFns.tsx | 15 +- pnpm-lock.yaml | 151 +++++++++++++++++- 9 files changed, 205 insertions(+), 25 deletions(-) create mode 100644 examples/example-app-router-playground/src/components/DropdownMenu.tsx diff --git a/examples/example-app-router-playground/package.json b/examples/example-app-router-playground/package.json index a84396c63..d1bcb8d5a 100644 --- a/examples/example-app-router-playground/package.json +++ b/examples/example-app-router-playground/package.json @@ -13,6 +13,7 @@ }, "dependencies": { "@mdx-js/react": "^3.0.1", + "@radix-ui/react-dropdown-menu": "^2.1.1", "lodash": "^4.17.21", "ms": "2.1.3", "next": "^14.2.4", diff --git a/examples/example-app-router-playground/src/app/[locale]/page.tsx b/examples/example-app-router-playground/src/app/[locale]/page.tsx index 47ad4b27b..8cf1b4db2 100644 --- a/examples/example-app-router-playground/src/app/[locale]/page.tsx +++ b/examples/example-app-router-playground/src/app/[locale]/page.tsx @@ -11,6 +11,7 @@ import LocaleSwitcher from '../../components/LocaleSwitcher'; import PageLayout from '../../components/PageLayout'; import MessagesAsPropsCounter from '../../components/client/01-MessagesAsPropsCounter'; import MessagesOnClientCounter from '../../components/client/02-MessagesOnClientCounter'; +import DropdownMenu from '@/components/DropdownMenu'; import {Link} from '@/i18n/routing'; type Props = { @@ -60,6 +61,7 @@ export default function Index({searchParams}: Props) { + ); } diff --git a/examples/example-app-router-playground/src/components/DropdownMenu.tsx b/examples/example-app-router-playground/src/components/DropdownMenu.tsx new file mode 100644 index 000000000..550da482a --- /dev/null +++ b/examples/example-app-router-playground/src/components/DropdownMenu.tsx @@ -0,0 +1,19 @@ +import * as Dropdown from '@radix-ui/react-dropdown-menu'; +import {Link as NextIntlLink} from '@/i18n/routing'; + +export default function DropdownMenu() { + return ( + + Toggle dropdown + + + Bar + + Link to about + + Foo + + + + ); +} diff --git a/examples/example-app-router-playground/tests/main.spec.ts b/examples/example-app-router-playground/tests/main.spec.ts index 61d20d080..c62eb3a7b 100644 --- a/examples/example-app-router-playground/tests/main.spec.ts +++ b/examples/example-app-router-playground/tests/main.spec.ts @@ -682,6 +682,14 @@ it('can use `getPahname` to define a canonical link', async ({page}) => { await expect(getCanonicalPathname()).resolves.toBe('/de/neuigkeiten/3'); }); +it('provides a `Link` that works with Radix Primitives', async ({page}) => { + await page.goto('/'); + await page.getByRole('button', {name: 'Toggle dropdown'}).click(); + await page.keyboard.press('ArrowDown'); + await page.keyboard.press('ArrowDown'); + await expect(page.getByText('Link to about')).toBeFocused(); +}); + describe('server actions', () => { it('can use `getTranslations` in server actions', async ({page}) => { await page.goto('/actions'); diff --git a/packages/next-intl/src/navigation/react-client/createNavigation.tsx b/packages/next-intl/src/navigation/react-client/createNavigation.tsx index 7293e54b1..47c25277e 100644 --- a/packages/next-intl/src/navigation/react-client/createNavigation.tsx +++ b/packages/next-intl/src/navigation/react-client/createNavigation.tsx @@ -78,9 +78,9 @@ export default function createNavigation< ); } - type LinkProps = Omit, 'nodeRef'>; + type LinkProps = ComponentProps; function Link(props: LinkProps, ref: LinkProps['ref']) { - return ; + return ; } const LinkWithRef = forwardRef(Link) as ( props: Prettify diff --git a/packages/next-intl/src/navigation/shared/BaseLink.tsx b/packages/next-intl/src/navigation/shared/BaseLink.tsx index d415e7d15..b3b2935d8 100644 --- a/packages/next-intl/src/navigation/shared/BaseLink.tsx +++ b/packages/next-intl/src/navigation/shared/BaseLink.tsx @@ -2,28 +2,28 @@ import NextLink from 'next/link'; import {usePathname} from 'next/navigation'; -import React, {ComponentProps, MouseEvent, useEffect, useState} from 'react'; +import React, { + ComponentProps, + forwardRef, + MouseEvent, + useEffect, + useState +} from 'react'; import useLocale from '../../react-client/useLocale'; import syncLocaleCookie from './syncLocaleCookie'; type Props = Omit, 'locale'> & { locale?: string; - nodeRef?: ComponentProps['ref']; unprefixConfig?: { domains: {[defaultLocale: string]: string}; pathname: string; }; }; -export default function BaseLink({ - href, - locale, - nodeRef, - onClick, - prefetch, - unprefixConfig, - ...rest -}: Props) { +function BaseLink( + {href, locale, onClick, prefetch, unprefixConfig, ...rest}: Props, + ref: ComponentProps['ref'] +) { const curLocale = useLocale(); const isChangingLocale = locale !== curLocale; const linkLocale = locale || curLocale; @@ -54,7 +54,7 @@ export default function BaseLink({ return ( - ); + return ; } const LegacyBaseLinkWithRef = forwardRef(LegacyBaseLink); diff --git a/packages/next-intl/src/navigation/shared/createSharedNavigationFns.tsx b/packages/next-intl/src/navigation/shared/createSharedNavigationFns.tsx index 3213b6302..30fae359a 100644 --- a/packages/next-intl/src/navigation/shared/createSharedNavigationFns.tsx +++ b/packages/next-intl/src/navigation/shared/createSharedNavigationFns.tsx @@ -2,7 +2,7 @@ import { permanentRedirect as nextPermanentRedirect, redirect as nextRedirect } from 'next/navigation'; -import React, {ComponentProps, use} from 'react'; +import React, {ComponentProps, forwardRef, use} from 'react'; import { receiveRoutingConfig, RoutingConfigLocalizedNavigation, @@ -94,11 +94,10 @@ export default function createSharedNavigationFns< locale?: string; } >; - function Link({ - href, - locale, - ...rest - }: LinkProps) { + function Link( + {href, locale, ...rest}: LinkProps, + ref: ComponentProps['ref'] + ) { let pathname, params; if (typeof href === 'object') { pathname = href.pathname; @@ -130,6 +129,7 @@ export default function createSharedNavigationFns< return ( ); } + const LinkWithRef = forwardRef(Link); type DomainConfigForAsNeeded = typeof routing extends undefined ? {} @@ -247,7 +248,7 @@ export default function createSharedNavigationFns< return { config, - Link, + Link: LinkWithRef, redirect, permanentRedirect, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index dd8c09cd7..840f45700 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -131,7 +131,7 @@ importers: version: 2.1.1 next: specifier: ^14.2.4 - version: 14.2.4(@babel/core@7.24.8)(@playwright/test@1.44.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + version: 14.2.4(@babel/core@7.24.7)(@playwright/test@1.44.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) next-intl: specifier: ^3.0.0 version: link:../../packages/next-intl @@ -322,6 +322,9 @@ importers: '@mdx-js/react': specifier: ^3.0.1 version: 3.0.1(@types/react@18.3.3)(react@18.3.1) + '@radix-ui/react-dropdown-menu': + specifier: ^2.1.1 + version: 2.1.1(@types/react-dom@18.3.0)(@types/react@18.3.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) lodash: specifier: ^4.17.21 version: 4.17.21 @@ -1346,6 +1349,7 @@ packages: '@babel/plugin-proposal-async-generator-functions@7.20.7': resolution: {integrity: sha512-xMbiLsn/8RK7Wq7VeVytytS2L6qE69bXPB10YCmMdDZbKF4okCqY74pI/jJQ/8U0b/F6NrT2+14b8/P9/3AMGA==} engines: {node: '>=6.9.0'} + deprecated: This proposal has been merged to the ECMAScript standard and thus this plugin is no longer maintained. Please use @babel/plugin-transform-async-generator-functions instead. peerDependencies: '@babel/core': ^7.0.0-0 @@ -1371,24 +1375,28 @@ packages: '@babel/plugin-proposal-nullish-coalescing-operator@7.18.6': resolution: {integrity: sha512-wQxQzxYeJqHcfppzBDnm1yAY0jSRkUXR2z8RePZYrKwMKgMlE8+Z6LUno+bd6LvbGh8Gltvy74+9pIYkr+XkKA==} engines: {node: '>=6.9.0'} + deprecated: This proposal has been merged to the ECMAScript standard and thus this plugin is no longer maintained. Please use @babel/plugin-transform-nullish-coalescing-operator instead. peerDependencies: '@babel/core': ^7.0.0-0 '@babel/plugin-proposal-object-rest-spread@7.20.7': resolution: {integrity: sha512-d2S98yCiLxDVmBmE8UjGcfPvNEUbA1U5q5WxaWFUGRzJSVAZqm5W6MbPct0jxnegUZ0niLeNX+IOzEs7wYg9Dg==} engines: {node: '>=6.9.0'} + deprecated: This proposal has been merged to the ECMAScript standard and thus this plugin is no longer maintained. Please use @babel/plugin-transform-object-rest-spread instead. peerDependencies: '@babel/core': ^7.0.0-0 '@babel/plugin-proposal-optional-catch-binding@7.18.6': resolution: {integrity: sha512-Q40HEhs9DJQyaZfUjjn6vE8Cv4GmMHCYuMGIWUnlxH6400VGxOuwWsPt4FxXxJkC/5eOzgn0z21M9gMT4MOhbw==} engines: {node: '>=6.9.0'} + deprecated: This proposal has been merged to the ECMAScript standard and thus this plugin is no longer maintained. Please use @babel/plugin-transform-optional-catch-binding instead. peerDependencies: '@babel/core': ^7.0.0-0 '@babel/plugin-proposal-optional-chaining@7.21.0': resolution: {integrity: sha512-p4zeefM72gpmEe2fkUr/OnOXpWEf8nAgk7ZYVqqfFiyIG7oFfVZcCrU64hWn5xp4tQ9LkV4bTIa5rD0KANpKNA==} engines: {node: '>=6.9.0'} + deprecated: This proposal has been merged to the ECMAScript standard and thus this plugin is no longer maintained. Please use @babel/plugin-transform-optional-chaining instead. peerDependencies: '@babel/core': ^7.0.0-0 @@ -2819,6 +2827,7 @@ packages: '@humanwhocodes/config-array@0.11.13': resolution: {integrity: sha512-JSBDMiDKSzQVngfRjOdFXgFfklaXI4K9nLF49Auh21lmBWRLIK3+xTErTWD4KU54pb6coM6ESE7Awz/FNU3zgQ==} engines: {node: '>=10.10.0'} + deprecated: Use @eslint/config-array instead '@humanwhocodes/module-importer@1.0.1': resolution: {integrity: sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==} @@ -2826,6 +2835,7 @@ packages: '@humanwhocodes/object-schema@2.0.1': resolution: {integrity: sha512-dvuCeX5fC9dXgJn9t+X5atfmgQAzUOWqS1254Gh0m6i8wKd10ebXkfNKiRK+1GWi/yTvvLDHpoxLr0xxxeslWw==} + deprecated: Use @eslint/object-schema instead '@hutson/parse-repository-url@5.0.0': resolution: {integrity: sha512-e5+YUKENATs1JgYHMzTr2MW/NDcXGfYFAuOQU8gJgF/kEh4EqKgfGrfLI67bMD4tbhZVlkigz/9YYwWcbOFthg==} @@ -3639,6 +3649,19 @@ packages: '@types/react-dom': optional: true + '@radix-ui/react-dropdown-menu@2.1.1': + resolution: {integrity: sha512-y8E+x9fBq9qvteD2Zwa4397pUVhYsh9iq44b5RD5qu1GMJWBCBuVg1hMyItbc6+zH00TxGRqd9Iot4wzf3OoBQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/react-focus-guards@1.1.0': resolution: {integrity: sha512-w6XZNUPVv6xCpZUqb/yN9DL6auvpGX3C/ee6Hdi16v2UUy25HV2Q5bcflsiDyT/g5RwbPQ/GIT1vLkeRb+ITBw==} peerDependencies: @@ -3670,6 +3693,19 @@ packages: '@types/react': optional: true + '@radix-ui/react-menu@2.1.1': + resolution: {integrity: sha512-oa3mXRRVjHi6DZu/ghuzdylyjaMXLymx83irM7hTxutQbD+7IhPKdMdRHD26Rm+kHRrWcrUkkRPv5pd47a2xFQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/react-popper@1.2.0': resolution: {integrity: sha512-ZnRMshKF43aBxVWPWvbj21+7TQCvhuULWJ4gNIKYpRlQt5xGRhLx66tMp8pya2UkGHTSlhpXwmjqltDYHhw7Vg==} peerDependencies: @@ -3696,6 +3732,19 @@ packages: '@types/react-dom': optional: true + '@radix-ui/react-presence@1.1.0': + resolution: {integrity: sha512-Gq6wuRN/asf9H/E/VzdKoUtT8GC9PQc9z40/vEr0VCJ4u5XvvhWIrSsCB6vD2/cH7ugTdSfYq9fLJCcM00acrQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/react-primitive@2.0.0': resolution: {integrity: sha512-ZSpFm0/uHa8zTvKBDjLFWLo8dkr4MBsiDLz0g3gMUwqgLHz9rTaRRGYDgvZPtBJgYCBKXkS9fzmoySgr8CO6Cw==} peerDependencies: @@ -3709,6 +3758,19 @@ packages: '@types/react-dom': optional: true + '@radix-ui/react-roving-focus@1.1.0': + resolution: {integrity: sha512-EA6AMGeq9AEeQDeSH0aZgG198qkfHSbvWTf1HvoDmOB5bBG/qTxjYMWUKMnYiV6J/iP/J8MEFSuB2zRU2n7ODA==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/react-select@2.1.1': resolution: {integrity: sha512-8iRDfyLtzxlprOo9IicnzvpsO1wNCkuwzzCM+Z5Rb5tNOpCdMvcc2AkzX0Fz+Tz9v6NJ5B/7EEgyZveo4FBRfQ==} peerDependencies: @@ -6324,6 +6386,7 @@ packages: copy-concurrently@1.0.5: resolution: {integrity: sha512-f2domd9fsVDFtaFcbaRZuYXwtdmnzqbADSwhSWYxYB/Q8zsdUUFMXVRwXGDMWmbEzAn1kdRrtI1T/KTFOL4X2A==} + deprecated: This package is no longer supported. copy-descriptor@0.1.1: resolution: {integrity: sha512-XgZ0pFcakEUlbwQEVNg3+QAis1FyTL3Qel9FYy8pSkQqoG3PNoT0bOCQtOXcOkur21r2Eq2kI+IE+gsmAEVlYw==} @@ -7745,6 +7808,7 @@ packages: figgy-pudding@3.5.2: resolution: {integrity: sha512-0btnI/H8f2pavGMN8w40mlSKOfTK2SVJmBfBeVIj3kNw0swwgzyRq0d5TJVOwodFmtvpPeWPN/MCcfuWF0Ezbw==} + deprecated: This module is no longer supported. figures@6.1.0: resolution: {integrity: sha512-d+l3qxjSesT4V7v2fh+QnmFnUWv9lSpjarhShNTgBOfA0ttejbQUAlHLitbjkoRiDulW0OPoQPYIGhIC8ohejg==} @@ -7979,6 +8043,7 @@ packages: fs-write-stream-atomic@1.0.10: resolution: {integrity: sha512-gehEzmPn2nAwr39eay+x3X34Ra+M2QlVUTLhkXPjWdeO8RF9kszk116avgBJM3ZyNHgHXBNx+VmPaFC36k0PzA==} + deprecated: This package is no longer supported. fs.realpath@1.0.0: resolution: {integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==} @@ -8173,10 +8238,12 @@ packages: glob@7.2.3: resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==} + deprecated: Glob versions prior to v9 are no longer supported glob@8.1.0: resolution: {integrity: sha512-r8hpEjiQEYlF2QU0df3dS+nxxSIreXQS1qRhMJM0Q5NDdR386C7jb7Hwwod8Fgiuex+k0GFjgft18yvxm5XoCQ==} engines: {node: '>=12'} + deprecated: Glob versions prior to v9 are no longer supported global-modules@2.0.0: resolution: {integrity: sha512-NGbfmJBp9x8IxyJSd1P+otYK8vonoJactOogrVfFRIAEY1ukil8RSKDz2Yo7wh1oihl51l/r6W4epkeKJHqL8A==} @@ -8653,6 +8720,7 @@ packages: inflight@1.0.6: resolution: {integrity: sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==} + deprecated: This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful. inherits@2.0.1: resolution: {integrity: sha512-8nWq2nLTAwd02jTqJExUYFSD/fKq6VH9Y/oG2accc/kdI0V98Bag8d5a4gi3XHz73rDWa2PvTtvcWYquKqSENA==} @@ -8744,10 +8812,12 @@ packages: is-accessor-descriptor@0.1.6: resolution: {integrity: sha512-e1BM1qnDbMRG3ll2U9dSK0UMHuWOs3pY3AtcFsmvwPtKL3MML/Q86i+GilLfvqEs4GW+ExB91tQ3Ig9noDIZ+A==} engines: {node: '>=0.10.0'} + deprecated: Please upgrade to v0.1.7 is-accessor-descriptor@1.0.0: resolution: {integrity: sha512-m5hnHTkcVsPfqx3AKlyttIPb7J+XykHvJP2B9bZDjlhLIoEq4XoK64Vg7boZlVWYK6LUY94dYPEE7Lh0ZkZKcQ==} engines: {node: '>=0.10.0'} + deprecated: Please upgrade to v1.0.1 is-alphabetical@2.0.1: resolution: {integrity: sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ==} @@ -8822,10 +8892,12 @@ packages: is-data-descriptor@0.1.4: resolution: {integrity: sha512-+w9D5ulSoBNlmw9OHn3U2v51SyoCd0he+bB3xMl62oijhrspxowjU+AIcDY0N3iEJbUEkB15IlMASQsxYigvXg==} engines: {node: '>=0.10.0'} + deprecated: Please upgrade to v0.1.5 is-data-descriptor@1.0.0: resolution: {integrity: sha512-jbRXy1FmtAoCjQkVmIVYwuuqDFUbaOeDjmed1tOGPrsMhtJA4rD9tkgA0F1qJ3gRFRXcHYVkdeaP50Q5rE/jLQ==} engines: {node: '>=0.10.0'} + deprecated: Please upgrade to v1.0.1 is-data-view@1.0.1: resolution: {integrity: sha512-AHkaJrsUVW6wq6JS8y3JnM/GJF/9cf+k20+iDzlSaJrinEo5+7vRiteOSwBhHRiAyQATN1AmY4hwzxJKPmYf+w==} @@ -10325,6 +10397,7 @@ packages: move-concurrently@1.0.1: resolution: {integrity: sha512-hdrFxZOycD/g6A6SoI2bB5NA/5NEqD0569+S47WZhPvm46sD50ZHdYaFmnua5lndde9rCHGjmfK7Z8BuCt/PcQ==} + deprecated: This package is no longer supported. mri@1.2.0: resolution: {integrity: sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==} @@ -10884,6 +10957,7 @@ packages: osenv@0.1.5: resolution: {integrity: sha512-0CWcCECdMVc2Rw3U5w9ZjqX6ga6ubk1xDVKxtBQPK7wis/0F2r9T6k4ydGYhecl7YUBxBVxhL5oisPsNxAPe2g==} + deprecated: This package is no longer supported. outdent@0.8.0: resolution: {integrity: sha512-KiOAIsdpUTcAXuykya5fnVVT+/5uS0Q1mrkRHcF89tpieSmY33O/tmc54CqwA+bfhbtEfZUNLHaPUiB9X3jt1A==} @@ -11686,6 +11760,10 @@ packages: q@1.5.1: resolution: {integrity: sha512-kV/CThkXo6xyFEZUugw/+pIOywXcDbFYgSct5cT3gqlbkBE1SJdwy6UQoZvodiWF/ckQLZyDE/Bu1M6gVu5lVw==} engines: {node: '>=0.6.0', teleport: '>=0.2.0'} + deprecated: |- + You or someone you depend on is using Q, the JavaScript Promise library that gave JavaScript developers strong feelings about promises. They can almost certainly migrate to the native JavaScript promise now. Thank you literally everyone for joining me in this bet against the odds. Be excellent to each other. + + (For a CapTP with native promises, see @endo/eventual-send and @endo/captp) qrcode-terminal@0.11.0: resolution: {integrity: sha512-Uu7ii+FQy4Qf82G4xu7ShHhjhGahEpCWc3x8UavY3CTcWV+ufmmCtwkr7ZKsX42jdL0kr1B5FKUeqJvAn51jzQ==} @@ -12214,14 +12292,17 @@ packages: rimraf@2.6.3: resolution: {integrity: sha512-mwqeW5XsA2qAejG46gYdENaxXjx9onRNCfn7L0duuP4hCuTIi/QO7PDK07KJfp1d+izWPrzEJDcSqBa0OZQriA==} + deprecated: Rimraf versions prior to v4 are no longer supported hasBin: true rimraf@2.7.1: resolution: {integrity: sha512-uWjbaKIK3T1OSVptzX7Nl6PvQ3qAGtKEtVRjRuazjfL3Bx5eI409VZSqgND+4UNnmzLVdPj9FqFJNPqBZFve4w==} + deprecated: Rimraf versions prior to v4 are no longer supported hasBin: true rimraf@3.0.2: resolution: {integrity: sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==} + deprecated: Rimraf versions prior to v4 are no longer supported hasBin: true ripemd160@2.0.2: @@ -18855,6 +18936,21 @@ snapshots: '@types/react': 18.3.3 '@types/react-dom': 18.3.0 + '@radix-ui/react-dropdown-menu@2.1.1(@types/react-dom@18.3.0)(@types/react@18.3.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@radix-ui/primitive': 1.1.0 + '@radix-ui/react-compose-refs': 1.1.0(@types/react@18.3.3)(react@18.3.1) + '@radix-ui/react-context': 1.1.0(@types/react@18.3.3)(react@18.3.1) + '@radix-ui/react-id': 1.1.0(@types/react@18.3.3)(react@18.3.1) + '@radix-ui/react-menu': 2.1.1(@types/react-dom@18.3.0)(@types/react@18.3.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-primitive': 2.0.0(@types/react-dom@18.3.0)(@types/react@18.3.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-use-controllable-state': 1.1.0(@types/react@18.3.3)(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.3 + '@types/react-dom': 18.3.0 + '@radix-ui/react-focus-guards@1.1.0(@types/react@18.3.3)(react@18.3.1)': dependencies: react: 18.3.1 @@ -18879,6 +18975,32 @@ snapshots: optionalDependencies: '@types/react': 18.3.3 + '@radix-ui/react-menu@2.1.1(@types/react-dom@18.3.0)(@types/react@18.3.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@radix-ui/primitive': 1.1.0 + '@radix-ui/react-collection': 1.1.0(@types/react-dom@18.3.0)(@types/react@18.3.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-compose-refs': 1.1.0(@types/react@18.3.3)(react@18.3.1) + '@radix-ui/react-context': 1.1.0(@types/react@18.3.3)(react@18.3.1) + '@radix-ui/react-direction': 1.1.0(@types/react@18.3.3)(react@18.3.1) + '@radix-ui/react-dismissable-layer': 1.1.0(@types/react-dom@18.3.0)(@types/react@18.3.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-focus-guards': 1.1.0(@types/react@18.3.3)(react@18.3.1) + '@radix-ui/react-focus-scope': 1.1.0(@types/react-dom@18.3.0)(@types/react@18.3.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-id': 1.1.0(@types/react@18.3.3)(react@18.3.1) + '@radix-ui/react-popper': 1.2.0(@types/react-dom@18.3.0)(@types/react@18.3.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-portal': 1.1.1(@types/react-dom@18.3.0)(@types/react@18.3.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-presence': 1.1.0(@types/react-dom@18.3.0)(@types/react@18.3.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-primitive': 2.0.0(@types/react-dom@18.3.0)(@types/react@18.3.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-roving-focus': 1.1.0(@types/react-dom@18.3.0)(@types/react@18.3.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-slot': 1.1.0(@types/react@18.3.3)(react@18.3.1) + '@radix-ui/react-use-callback-ref': 1.1.0(@types/react@18.3.3)(react@18.3.1) + aria-hidden: 1.2.4 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + react-remove-scroll: 2.5.7(@types/react@18.3.3)(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.3 + '@types/react-dom': 18.3.0 + '@radix-ui/react-popper@1.2.0(@types/react-dom@18.3.0)(@types/react@18.3.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: '@floating-ui/react-dom': 2.1.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -18907,6 +19029,16 @@ snapshots: '@types/react': 18.3.3 '@types/react-dom': 18.3.0 + '@radix-ui/react-presence@1.1.0(@types/react-dom@18.3.0)(@types/react@18.3.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.0(@types/react@18.3.3)(react@18.3.1) + '@radix-ui/react-use-layout-effect': 1.1.0(@types/react@18.3.3)(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.3 + '@types/react-dom': 18.3.0 + '@radix-ui/react-primitive@2.0.0(@types/react-dom@18.3.0)(@types/react@18.3.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: '@radix-ui/react-slot': 1.1.0(@types/react@18.3.3)(react@18.3.1) @@ -18916,6 +19048,23 @@ snapshots: '@types/react': 18.3.3 '@types/react-dom': 18.3.0 + '@radix-ui/react-roving-focus@1.1.0(@types/react-dom@18.3.0)(@types/react@18.3.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@radix-ui/primitive': 1.1.0 + '@radix-ui/react-collection': 1.1.0(@types/react-dom@18.3.0)(@types/react@18.3.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-compose-refs': 1.1.0(@types/react@18.3.3)(react@18.3.1) + '@radix-ui/react-context': 1.1.0(@types/react@18.3.3)(react@18.3.1) + '@radix-ui/react-direction': 1.1.0(@types/react@18.3.3)(react@18.3.1) + '@radix-ui/react-id': 1.1.0(@types/react@18.3.3)(react@18.3.1) + '@radix-ui/react-primitive': 2.0.0(@types/react-dom@18.3.0)(@types/react@18.3.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-use-callback-ref': 1.1.0(@types/react@18.3.3)(react@18.3.1) + '@radix-ui/react-use-controllable-state': 1.1.0(@types/react@18.3.3)(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.3 + '@types/react-dom': 18.3.0 + '@radix-ui/react-select@2.1.1(@types/react-dom@18.3.0)(@types/react@18.3.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: '@radix-ui/number': 1.1.0 From 07395d341e21ba163c2c8c4f3f4ec5c22d5885e0 Mon Sep 17 00:00:00 2001 From: Jan Amann Date: Mon, 30 Sep 2024 15:33:50 +0200 Subject: [PATCH 54/62] bump sizes --- packages/next-intl/.size-limit.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/next-intl/.size-limit.ts b/packages/next-intl/.size-limit.ts index b6a53fcb4..6b3c1b15b 100644 --- a/packages/next-intl/.size-limit.ts +++ b/packages/next-intl/.size-limit.ts @@ -33,7 +33,7 @@ const config: SizeLimitConfig = [ name: 'import {createSharedPathnamesNavigation} from \'next-intl/navigation\' (react-server)', path: 'dist/production/navigation.react-server.js', import: '{createSharedPathnamesNavigation}', - limit: '16.455 KB' + limit: '16.485 KB' }, { name: 'import {createLocalizedPathnamesNavigation} from \'next-intl/navigation\' (react-server)', @@ -45,7 +45,7 @@ const config: SizeLimitConfig = [ name: 'import {createNavigation} from \'next-intl/navigation\' (react-server)', path: 'dist/production/navigation.react-server.js', import: '{createNavigation}', - limit: '16.445 KB' + limit: '16.495 KB' }, { name: 'import * from \'next-intl/server\' (react-client)', From c8f07611a85da100aff232b2c686b404bbff7e4b Mon Sep 17 00:00:00 2001 From: Jan Amann Date: Mon, 30 Sep 2024 16:07:34 +0200 Subject: [PATCH 55/62] migrate example-app-router --- docs/pages/docs/routing/navigation.mdx | 3 +-- examples/example-app-router/src/app/sitemap.ts | 2 +- .../example-app-router/src/components/NavigationLink.tsx | 6 +++--- examples/example-app-router/src/config.ts | 4 ++-- examples/example-app-router/src/i18n/routing.ts | 4 ++-- 5 files changed, 9 insertions(+), 10 deletions(-) diff --git a/docs/pages/docs/routing/navigation.mdx b/docs/pages/docs/routing/navigation.mdx index 89043a117..ca2a84758 100644 --- a/docs/pages/docs/routing/navigation.mdx +++ b/docs/pages/docs/routing/navigation.mdx @@ -70,11 +70,10 @@ import {Link} from '@/i18n/routing'; Users // You can override the `locale` to switch to another language +// (this will set the `hreflang` attribute on the anchor tag) Switch to German ``` -If you're providing the `locale` prop, the `hreflang` attribute will be set accordingly on the anchor tag. - Depending on if you're using the [`pathnames`](/docs/routing#pathnames) setting, dynamic params can either be passed as: ```tsx diff --git a/examples/example-app-router/src/app/sitemap.ts b/examples/example-app-router/src/app/sitemap.ts index 7ae7ca2eb..0fefe33a2 100644 --- a/examples/example-app-router/src/app/sitemap.ts +++ b/examples/example-app-router/src/app/sitemap.ts @@ -21,5 +21,5 @@ function getEntry(href: Href) { function getUrl(href: Href, locale: Locale) { const pathname = getPathname({locale, href}); - return `${host}/${locale}${pathname === '/' ? '' : pathname}`; + return host + pathname; } diff --git a/examples/example-app-router/src/components/NavigationLink.tsx b/examples/example-app-router/src/components/NavigationLink.tsx index 5a25b474d..b0db7e50f 100644 --- a/examples/example-app-router/src/components/NavigationLink.tsx +++ b/examples/example-app-router/src/components/NavigationLink.tsx @@ -3,12 +3,12 @@ import clsx from 'clsx'; import {useSelectedLayoutSegment} from 'next/navigation'; import {ComponentProps} from 'react'; -import {Link, Pathnames} from '@/i18n/routing'; +import {Link} from '@/i18n/routing'; -export default function NavigationLink({ +export default function NavigationLink({ href, ...rest -}: ComponentProps>) { +}: ComponentProps) { const selectedLayoutSegment = useSelectedLayoutSegment(); const pathname = selectedLayoutSegment ? `/${selectedLayoutSegment}` : '/'; const isActive = pathname === href; diff --git a/examples/example-app-router/src/config.ts b/examples/example-app-router/src/config.ts index 6c36de5f5..53d3b1d35 100644 --- a/examples/example-app-router/src/config.ts +++ b/examples/example-app-router/src/config.ts @@ -1,4 +1,4 @@ export const port = process.env.PORT || 3000; -export const host = process.env.VERCEL_URL - ? `https://${process.env.VERCEL_URL}` +export const host = process.env.VERCEL_PROJECT_PRODUCTION_URL + ? `https://${process.env.VERCEL_PROJECT_PRODUCTION_URL}` : `http://localhost:${port}`; diff --git a/examples/example-app-router/src/i18n/routing.ts b/examples/example-app-router/src/i18n/routing.ts index 8026e3433..001b856ff 100644 --- a/examples/example-app-router/src/i18n/routing.ts +++ b/examples/example-app-router/src/i18n/routing.ts @@ -1,4 +1,4 @@ -import {createLocalizedPathnamesNavigation} from 'next-intl/navigation'; +import {createNavigation} from 'next-intl/navigation'; import {defineRouting} from 'next-intl/routing'; export const routing = defineRouting({ @@ -17,4 +17,4 @@ export type Pathnames = keyof typeof routing.pathnames; export type Locale = (typeof routing.locales)[number]; export const {Link, getPathname, redirect, usePathname, useRouter} = - createLocalizedPathnamesNavigation(routing); + createNavigation(routing); From adba9d58afa3308aeef0c5075dbe07bbdd6f5130 Mon Sep 17 00:00:00 2001 From: Jan Amann Date: Mon, 30 Sep 2024 16:10:42 +0200 Subject: [PATCH 56/62] migrate example-app-router-migration --- examples/example-app-router-migration/src/i18n/routing.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/examples/example-app-router-migration/src/i18n/routing.ts b/examples/example-app-router-migration/src/i18n/routing.ts index 371487a7f..986738f3f 100644 --- a/examples/example-app-router-migration/src/i18n/routing.ts +++ b/examples/example-app-router-migration/src/i18n/routing.ts @@ -1,4 +1,4 @@ -import {createSharedPathnamesNavigation} from 'next-intl/navigation'; +import {createNavigation} from 'next-intl/navigation'; import {defineRouting} from 'next-intl/routing'; export const routing = defineRouting({ @@ -7,4 +7,4 @@ export const routing = defineRouting({ }); export const {Link, redirect, usePathname, useRouter} = - createSharedPathnamesNavigation(routing); + createNavigation(routing); From 07f75e5b7d108e516411ea8a91167e0fe0ef4de6 Mon Sep 17 00:00:00 2001 From: Jan Amann Date: Mon, 30 Sep 2024 16:12:29 +0200 Subject: [PATCH 57/62] migrate example-app-router-next-auth --- examples/example-app-router-next-auth/src/i18n/routing.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/examples/example-app-router-next-auth/src/i18n/routing.ts b/examples/example-app-router-next-auth/src/i18n/routing.ts index 30c0fa8de..ab99b9837 100644 --- a/examples/example-app-router-next-auth/src/i18n/routing.ts +++ b/examples/example-app-router-next-auth/src/i18n/routing.ts @@ -1,4 +1,4 @@ -import {createSharedPathnamesNavigation} from 'next-intl/navigation'; +import {createNavigation} from 'next-intl/navigation'; import {defineRouting} from 'next-intl/routing'; export const routing = defineRouting({ @@ -8,4 +8,4 @@ export const routing = defineRouting({ }); export const {Link, redirect, usePathname, useRouter} = - createSharedPathnamesNavigation(routing); + createNavigation(routing); From 8b63e053762a0399b56e63407863e19dbe0a0d27 Mon Sep 17 00:00:00 2001 From: Jan Amann Date: Mon, 30 Sep 2024 18:26:47 +0200 Subject: [PATCH 58/62] wording --- docs/pages/docs/routing.mdx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/pages/docs/routing.mdx b/docs/pages/docs/routing.mdx index 6115fe5d6..66d56304a 100644 --- a/docs/pages/docs/routing.mdx +++ b/docs/pages/docs/routing.mdx @@ -93,7 +93,7 @@ If you'd like to provide a locale to `next-intl`, e.g. based on user settings, y However, you can also configure the middleware to never show a locale prefix in the URL, which can be helpful in the following cases: 1. You want to use [domain-based routing](#domains) and have only one locale per domain -2. You want to use a cookie to determine locale while enabling static rendering +2. You want to use a cookie to determine the locale while enabling static rendering ```tsx filename="routing.ts" {5} import {defineRouting} from 'next-intl/routing'; @@ -405,7 +405,7 @@ export const routing = defineRouting({ Since domains can have different default locales, this combination requires some tradeoffs that apply to the [navigation APIs](/docs/routing/navigation) in order for `next-intl` to avoid reading the current host on the server side (which would prevent the usage of static rendering). 1. [``](/docs/routing/navigation#link): This component will always render a locale prefix on the server side, even for the default locale of a given domain. However, during hydration on the client side, the prefix is potentially removed, if the default locale of the current domain is used. Note that the temporarily prefixed pathname will always be valid, however the middleware will potentially clean up a superfluous prefix via a redirect if the user clicks on a link before hydration. -2. [`redirect`](/docs/routing/navigation#redirect): When calling this function, a locale prefix is always added, regardless of the current locale and domain. However, similar to the handling with ``, the middleware will potentially clean up a superfluous prefix. +2. [`redirect`](/docs/routing/navigation#redirect): When calling this function, a locale prefix is always added, regardless of the provided locale. However, similar to the handling with ``, the middleware will potentially clean up a superfluous prefix. 3. [`getPathname`](/docs/routing/navigation#getpathname): This function requires that a `domain` is passed as part of the arguments in order to avoid ambiguity. This can either be provided statically (e.g. when used in a sitemap), or read from a header like [`x-forwarded-host`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/X-Forwarded-Host). ```tsx From b79466edb28307e8585e09a05bbb20718cd0df6f Mon Sep 17 00:00:00 2001 From: Jan Amann Date: Tue, 1 Oct 2024 15:51:38 +0200 Subject: [PATCH 59/62] some domain e2e tests --- .../next.config.mjs | 4 +- .../runPlaywright.mjs | 10 ++++- .../src/i18n/routing.ts | 15 ++++++- .../tests/domains.spec.ts | 42 +++++++++++++++++++ .../src/navigation/createNavigation.test.tsx | 21 ++++++++++ .../src/navigation/shared/BaseLink.tsx | 25 ++++++++--- .../shared/createSharedNavigationFns.tsx | 10 +++-- 7 files changed, 112 insertions(+), 15 deletions(-) create mode 100644 examples/example-app-router-playground/tests/domains.spec.ts diff --git a/examples/example-app-router-playground/next.config.mjs b/examples/example-app-router-playground/next.config.mjs index 14a138167..0585df3ed 100644 --- a/examples/example-app-router-playground/next.config.mjs +++ b/examples/example-app-router-playground/next.config.mjs @@ -8,8 +8,8 @@ const withMdx = mdxPlugin(); export default withMdx( withNextIntl({ - trailingSlash: process.env.USE_CASE === 'trailing-slash', - basePath: process.env.USE_CASE === 'base-path' ? '/base/path' : undefined, + trailingSlash: process.env.NEXT_PUBLIC_USE_CASE === 'trailing-slash', + basePath: process.env.NEXT_PUBLIC_USE_CASE === 'base-path' ? '/base/path' : undefined, experimental: { staleTimes: { // Next.js 14.2 broke `locale-prefix-never.spec.ts`. diff --git a/examples/example-app-router-playground/runPlaywright.mjs b/examples/example-app-router-playground/runPlaywright.mjs index 12129d0b1..45779d1fc 100644 --- a/examples/example-app-router-playground/runPlaywright.mjs +++ b/examples/example-app-router-playground/runPlaywright.mjs @@ -1,11 +1,17 @@ import {execSync} from 'child_process'; -const useCases = ['main', 'locale-prefix-never', 'trailing-slash', 'base-path']; +const useCases = [ + 'main', + 'locale-prefix-never', + 'trailing-slash', + 'base-path', + 'domains' +]; for (const useCase of useCases) { // eslint-disable-next-line no-console console.log(`Running tests for use case: ${useCase}`); - const command = `USE_CASE=${useCase} pnpm build && USE_CASE=${useCase} TEST_MATCH=${useCase}.spec.ts playwright test`; + const command = `NEXT_PUBLIC_USE_CASE=${useCase} pnpm build && NEXT_PUBLIC_USE_CASE=${useCase} TEST_MATCH=${useCase}.spec.ts playwright test`; execSync(command, {stdio: 'inherit'}); } diff --git a/examples/example-app-router-playground/src/i18n/routing.ts b/examples/example-app-router-playground/src/i18n/routing.ts index fee57c938..7a9221ce4 100644 --- a/examples/example-app-router-playground/src/i18n/routing.ts +++ b/examples/example-app-router-playground/src/i18n/routing.ts @@ -5,7 +5,7 @@ export const routing = defineRouting({ locales: ['en', 'de', 'es', 'ja'], defaultLocale: 'en', localePrefix: - process.env.USE_CASE === 'locale-prefix-never' + process.env.NEXT_PUBLIC_USE_CASE === 'locale-prefix-never' ? 'never' : { mode: 'as-needed', @@ -13,6 +13,19 @@ export const routing = defineRouting({ es: '/spain' } }, + domains: + process.env.NEXT_PUBLIC_USE_CASE === 'domains' + ? [ + { + domain: 'example.com', + defaultLocale: 'en' + }, + { + domain: 'example.de', + defaultLocale: 'de' + } + ] + : undefined, pathnames: { '/': '/', '/client': '/client', diff --git a/examples/example-app-router-playground/tests/domains.spec.ts b/examples/example-app-router-playground/tests/domains.spec.ts new file mode 100644 index 000000000..fa01bf3f8 --- /dev/null +++ b/examples/example-app-router-playground/tests/domains.spec.ts @@ -0,0 +1,42 @@ +import {test as it, expect, chromium} from '@playwright/test'; + +it('can use config based on the default locale on an unknown domain', async ({ + page +}) => { + await page.goto('/'); + await expect(page.getByRole('heading', {name: 'Home'})).toBeVisible(); + await expect(page).toHaveURL('/'); + await page.getByRole('link', {name: 'Client page'}).click(); + await expect(page).toHaveURL('/client'); + + await page.goto('/'); + await page.getByRole('link', {name: 'Switch to German'}).click(); + await expect(page).toHaveURL('/de'); +}); + +it('can use a secondary locale unprefixed if the domain has specified it as the default locale', async () => { + const browser = await chromium.launch({ + args: ['--host-resolver-rules=MAP example.de 127.0.0.1:3000'] + }); + + const page = await browser.newPage(); + await page.route('**/*', (route) => + route.continue({ + headers: { + 'accept-language': 'de', + 'x-forwarded-port': '80' + } + }) + ); + + await page.goto('http://example.de'); + await expect(page).toHaveURL('http://example.de'); // (no redirect) + await expect(page.getByRole('heading', {name: 'Start'})).toBeVisible(); + await page.getByRole('link', {name: 'Client-Seite'}).click(); + await expect(page).toHaveURL('http://example.de/client'); + await page.getByRole('link', {name: 'Start'}).click(); + await expect(page).toHaveURL('http://example.de'); + await page.getByRole('link', {name: 'Zu Englisch wechseln'}).click(); + await expect(page).toHaveURL('http://example.de/en'); + await expect(page.getByRole('heading', {name: 'Home'})).toBeVisible(); +}); diff --git a/packages/next-intl/src/navigation/createNavigation.test.tsx b/packages/next-intl/src/navigation/createNavigation.test.tsx index e72d51c89..c7d2978d3 100644 --- a/packages/next-intl/src/navigation/createNavigation.test.tsx +++ b/packages/next-intl/src/navigation/createNavigation.test.tsx @@ -866,6 +866,18 @@ describe.each([ ).toBe('/about'); }); + it('renders a prefix when when linking to a secondary locale on an unknown domain', () => { + mockLocation({host: 'localhost:3000'}); + render( + + Über uns + + ); + expect( + screen.getByRole('link', {name: 'Über uns'}).getAttribute('href') + ).toBe('/de/about'); + }); + it('renders a prefix when currently on a secondary locale', () => { mockLocation({host: 'example.de'}); mockCurrentLocale('en'); @@ -875,6 +887,15 @@ describe.each([ ).toBe('/en/about'); }); + it('does not render a prefix when currently on a domain with a different default locale', () => { + mockLocation({host: 'example.de'}); + mockCurrentLocale('de'); + render(About); + expect( + screen.getByRole('link', {name: 'About'}).getAttribute('href') + ).toBe('/about'); + }); + it('renders a prefix when currently on a secondary locale and linking to the default locale', () => { mockLocation({host: 'example.de'}); mockCurrentLocale('en'); diff --git a/packages/next-intl/src/navigation/shared/BaseLink.tsx b/packages/next-intl/src/navigation/shared/BaseLink.tsx index b3b2935d8..c19bd9367 100644 --- a/packages/next-intl/src/navigation/shared/BaseLink.tsx +++ b/packages/next-intl/src/navigation/shared/BaseLink.tsx @@ -14,24 +14,37 @@ import syncLocaleCookie from './syncLocaleCookie'; type Props = Omit, 'locale'> & { locale?: string; - unprefixConfig?: { - domains: {[defaultLocale: string]: string}; + defaultLocale?: string; + /** Special case for `localePrefix: 'as-needed'` and `domains`. */ + unprefixed?: { + domains: {[domain: string]: string}; pathname: string; }; }; function BaseLink( - {href, locale, onClick, prefetch, unprefixConfig, ...rest}: Props, + {defaultLocale, href, locale, onClick, prefetch, unprefixed, ...rest}: Props, ref: ComponentProps['ref'] ) { const curLocale = useLocale(); const isChangingLocale = locale !== curLocale; const linkLocale = locale || curLocale; - const host = useHost(); + const finalHref = - unprefixConfig && unprefixConfig.domains[linkLocale] === host - ? unprefixConfig.pathname + // Only after hydration (to avoid mismatches) + host && + // If there is an `unprefixed` prop, the + // `defaultLocale` might differ by domain + unprefixed && + // Unprefix the pathname if a domain + (unprefixed.domains[host] === linkLocale || + // For unknown domains, remove the prefix for the global + // `defaultLocale` (e.g. on localhost) + (!Object.keys(unprefixed.domains).includes(host) && + curLocale === defaultLocale && + !locale)) + ? unprefixed.pathname : href; // The types aren't entirely correct here. Outside of Next.js diff --git a/packages/next-intl/src/navigation/shared/createSharedNavigationFns.tsx b/packages/next-intl/src/navigation/shared/createSharedNavigationFns.tsx index 30fae359a..9b6d9ea65 100644 --- a/packages/next-intl/src/navigation/shared/createSharedNavigationFns.tsx +++ b/packages/next-intl/src/navigation/shared/createSharedNavigationFns.tsx @@ -78,13 +78,13 @@ export default function createSharedNavigationFns< // that the user might get redirected again if the middleware detects that the // prefix is not needed. const forcePrefixSsr = - (config.localePrefix.mode === 'as-needed' && 'domains' in config) || + (config.localePrefix.mode === 'as-needed' && (config as any).domains) || undefined; type LinkProps = Prettify< Omit< ComponentProps, - 'href' | 'localePrefix' | 'unprefixConfig' + 'href' | 'localePrefix' | 'unprefixed' | 'defaultLocale' > & { /** @see https://next-intl-docs.vercel.app/docs/routing/navigation#link */ href: [AppPathnames] extends [never] @@ -130,6 +130,8 @@ export default function createSharedNavigationFns< return ( ) => { // @ts-expect-error -- This is ok - acc[domain.defaultLocale] = domain.domain; + acc[domain.domain] = domain.defaultLocale; return acc; }, {} From 37b587248f655f2eeeb41659d27f68ed69ec4f67 Mon Sep 17 00:00:00 2001 From: Jan Amann Date: Tue, 1 Oct 2024 16:54:13 +0200 Subject: [PATCH 60/62] simplify Link on react-client impl --- .../react-client/createNavigation.tsx | 24 +++++-------------- 1 file changed, 6 insertions(+), 18 deletions(-) diff --git a/packages/next-intl/src/navigation/react-client/createNavigation.tsx b/packages/next-intl/src/navigation/react-client/createNavigation.tsx index 47c25277e..ec87381c3 100644 --- a/packages/next-intl/src/navigation/react-client/createNavigation.tsx +++ b/packages/next-intl/src/navigation/react-client/createNavigation.tsx @@ -2,7 +2,7 @@ import { useRouter as useNextRouter, usePathname as useNextPathname } from 'next/navigation'; -import React, {ComponentProps, forwardRef, ReactElement, useMemo} from 'react'; +import {useMemo} from 'react'; import useLocale from '../../react-client/useLocale'; import { RoutingConfigLocalizedNavigation, @@ -14,7 +14,6 @@ import { Locales, Pathnames } from '../../routing/types'; -import {Prettify} from '../../shared/types'; import createSharedNavigationFns from '../shared/createSharedNavigationFns'; import syncLocaleCookie from '../shared/syncLocaleCookie'; import {getRoute} from '../shared/utils'; @@ -47,12 +46,10 @@ export default function createNavigation< return useLocale() as Locale; } - const { - Link: BaseLink, - config, - getPathname, - ...redirects - } = createSharedNavigationFns(useTypedLocale, routing); + const {Link, config, getPathname, ...redirects} = createSharedNavigationFns( + useTypedLocale, + routing + ); /** @see https://next-intl-docs.vercel.app/docs/routing/navigation#usepathname */ function usePathname(): [AppPathnames] extends [never] @@ -78,15 +75,6 @@ export default function createNavigation< ); } - type LinkProps = ComponentProps; - function Link(props: LinkProps, ref: LinkProps['ref']) { - return ; - } - const LinkWithRef = forwardRef(Link) as ( - props: Prettify - ) => ReactElement; - (LinkWithRef as any).displayName = 'Link'; - function useRouter() { const router = useNextRouter(); const curLocale = useTypedLocale(); @@ -145,7 +133,7 @@ export default function createNavigation< return { ...redirects, - Link: LinkWithRef, + Link, usePathname, useRouter, getPathname From a08271a5b18987474dd294adde59b712a3dd94ab Mon Sep 17 00:00:00 2001 From: Jan Amann Date: Tue, 1 Oct 2024 17:11:53 +0200 Subject: [PATCH 61/62] fix playwright --- examples/example-app-router-playground/.gitignore | 1 + examples/example-app-router-playground/playwright.config.ts | 3 +++ .../example-app-router-playground/tests/domains.spec.ts | 4 ++-- packages/next-intl/src/navigation/shared/BaseLink.tsx | 6 +++--- 4 files changed, 9 insertions(+), 5 deletions(-) diff --git a/examples/example-app-router-playground/.gitignore b/examples/example-app-router-playground/.gitignore index ef113c3c4..d61873784 100644 --- a/examples/example-app-router-playground/.gitignore +++ b/examples/example-app-router-playground/.gitignore @@ -4,3 +4,4 @@ tsconfig.tsbuildinfo *storybook.log storybook-static +test-results diff --git a/examples/example-app-router-playground/playwright.config.ts b/examples/example-app-router-playground/playwright.config.ts index cdd5c90ba..e6a7e8000 100644 --- a/examples/example-app-router-playground/playwright.config.ts +++ b/examples/example-app-router-playground/playwright.config.ts @@ -5,6 +5,9 @@ import {devices} from '@playwright/test'; // Use a distinct port on CI to avoid conflicts during concurrent tests const PORT = process.env.CI ? 3004 : 3000; +// Forward to specs +process.env.PORT = PORT.toString(); + const config: PlaywrightTestConfig = { retries: process.env.CI ? 1 : 0, testMatch: process.env.TEST_MATCH || 'main.spec.ts', diff --git a/examples/example-app-router-playground/tests/domains.spec.ts b/examples/example-app-router-playground/tests/domains.spec.ts index fa01bf3f8..f1aac49dd 100644 --- a/examples/example-app-router-playground/tests/domains.spec.ts +++ b/examples/example-app-router-playground/tests/domains.spec.ts @@ -16,7 +16,7 @@ it('can use config based on the default locale on an unknown domain', async ({ it('can use a secondary locale unprefixed if the domain has specified it as the default locale', async () => { const browser = await chromium.launch({ - args: ['--host-resolver-rules=MAP example.de 127.0.0.1:3000'] + args: ['--host-resolver-rules=MAP example.de 127.0.0.1:' + process.env.PORT] }); const page = await browser.newPage(); @@ -24,7 +24,7 @@ it('can use a secondary locale unprefixed if the domain has specified it as the route.continue({ headers: { 'accept-language': 'de', - 'x-forwarded-port': '80' + 'x-forwarded-port': '80' // (playwright default) } }) ); diff --git a/packages/next-intl/src/navigation/shared/BaseLink.tsx b/packages/next-intl/src/navigation/shared/BaseLink.tsx index c19bd9367..7ce46d92c 100644 --- a/packages/next-intl/src/navigation/shared/BaseLink.tsx +++ b/packages/next-intl/src/navigation/shared/BaseLink.tsx @@ -37,10 +37,10 @@ function BaseLink( // If there is an `unprefixed` prop, the // `defaultLocale` might differ by domain unprefixed && - // Unprefix the pathname if a domain + // Unprefix the pathname if a domain matches (unprefixed.domains[host] === linkLocale || - // For unknown domains, remove the prefix for the global - // `defaultLocale` (e.g. on localhost) + // … and handle unknown domains by applying the + // global `defaultLocale` (e.g. on localhost) (!Object.keys(unprefixed.domains).includes(host) && curLocale === defaultLocale && !locale)) From 6b2087378db405091d04a7a79537c20c4646a1ed Mon Sep 17 00:00:00 2001 From: Jan Amann Date: Tue, 1 Oct 2024 17:23:35 +0200 Subject: [PATCH 62/62] bump sizes --- packages/next-intl/.size-limit.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/next-intl/.size-limit.ts b/packages/next-intl/.size-limit.ts index 6b3c1b15b..3e357fb49 100644 --- a/packages/next-intl/.size-limit.ts +++ b/packages/next-intl/.size-limit.ts @@ -15,31 +15,31 @@ const config: SizeLimitConfig = [ name: 'import {createSharedPathnamesNavigation} from \'next-intl/navigation\' (react-client)', path: 'dist/production/navigation.react-client.js', import: '{createSharedPathnamesNavigation}', - limit: '3.855 KB' + limit: '3.885 KB' }, { name: 'import {createLocalizedPathnamesNavigation} from \'next-intl/navigation\' (react-client)', path: 'dist/production/navigation.react-client.js', import: '{createLocalizedPathnamesNavigation}', - limit: '3.855 KB' + limit: '3.885 KB' }, { name: 'import {createNavigation} from \'next-intl/navigation\' (react-client)', path: 'dist/production/navigation.react-client.js', import: '{createNavigation}', - limit: '3.865 KB' + limit: '3.885 KB' }, { name: 'import {createSharedPathnamesNavigation} from \'next-intl/navigation\' (react-server)', path: 'dist/production/navigation.react-server.js', import: '{createSharedPathnamesNavigation}', - limit: '16.485 KB' + limit: '16.515 KB' }, { name: 'import {createLocalizedPathnamesNavigation} from \'next-intl/navigation\' (react-server)', path: 'dist/production/navigation.react-server.js', import: '{createLocalizedPathnamesNavigation}', - limit: '16.475 KB' + limit: '16.545 KB' }, { name: 'import {createNavigation} from \'next-intl/navigation\' (react-server)',