From 6508ddc35ecc95f6dce8b95ecde2734a169579b8 Mon Sep 17 00:00:00 2001 From: Pol Vallverdu <86187892+polvallverdu@users.noreply.github.com> Date: Fri, 9 Feb 2024 10:52:13 +0100 Subject: [PATCH] feat: Add support for `permanentRedirect`in navigation APIs (#850 by @polvallverdu) Fixes https://github.com/amannn/next-intl/issues/715 --------- Co-authored-by: Jan Amann --- docs/pages/docs/routing/navigation.mdx | 4 + packages/next-intl/package.json | 4 +- .../react-client/clientPermanentRedirect.tsx | 22 ++ .../createLocalizedPathnamesNavigation.tsx | 12 + .../createSharedPathnamesNavigation.tsx | 9 + .../createLocalizedPathnamesNavigation.tsx | 11 + .../createSharedPathnamesNavigation.tsx | 9 + .../react-server/serverPermanentRedirect.tsx | 11 + .../shared/basePermanentRedirect.tsx | 22 ++ ...reateLocalizedPathnamesNavigation.test.tsx | 211 +++++++++++++++++- .../createSharedPathnamesNavigation.test.tsx | 115 +++++++++- 11 files changed, 408 insertions(+), 22 deletions(-) create mode 100644 packages/next-intl/src/navigation/react-client/clientPermanentRedirect.tsx create mode 100644 packages/next-intl/src/navigation/react-server/serverPermanentRedirect.tsx create mode 100644 packages/next-intl/src/navigation/shared/basePermanentRedirect.tsx diff --git a/docs/pages/docs/routing/navigation.mdx b/docs/pages/docs/routing/navigation.mdx index 51813de3b..5969fcf12 100644 --- a/docs/pages/docs/routing/navigation.mdx +++ b/docs/pages/docs/routing/navigation.mdx @@ -512,6 +512,10 @@ redirect({ + + [`permanentRedirect`](https://nextjs.org/docs/app/api-reference/functions/permanentRedirect) is supported too. + + ### `getPathname` If you need to construct a particular pathname based on a locale, you can call the `getPathname` function. This can for example be useful to retrieve a [canonical link](https://nextjs.org/docs/app/api-reference/functions/generate-metadata#alternates) for a page that accepts search params. diff --git a/packages/next-intl/package.json b/packages/next-intl/package.json index 587a82612..2a7de4da3 100644 --- a/packages/next-intl/package.json +++ b/packages/next-intl/package.json @@ -122,11 +122,11 @@ }, { "path": "dist/production/navigation.react-client.js", - "limit": "2.84 KB" + "limit": "2.89 KB" }, { "path": "dist/production/navigation.react-server.js", - "limit": "2.95 KB" + "limit": "3.01 KB" }, { "path": "dist/production/server.react-client.js", diff --git a/packages/next-intl/src/navigation/react-client/clientPermanentRedirect.tsx b/packages/next-intl/src/navigation/react-client/clientPermanentRedirect.tsx new file mode 100644 index 000000000..417e74376 --- /dev/null +++ b/packages/next-intl/src/navigation/react-client/clientPermanentRedirect.tsx @@ -0,0 +1,22 @@ +import useLocale from '../../react-client/useLocale'; +import {LocalePrefix, ParametersExceptFirst} from '../../shared/types'; +import basePermanentRedirect from '../shared/basePermanentRedirect'; + +export default function clientPermanentRedirect( + params: {localePrefix?: LocalePrefix; pathname: string}, + ...args: ParametersExceptFirst +) { + let locale; + try { + // eslint-disable-next-line react-hooks/rules-of-hooks -- Reading from context here is fine, since `redirect` should be called during render + locale = useLocale(); + } catch (e) { + throw new Error( + process.env.NODE_ENV !== 'production' + ? '`permanentRedirect()` can only be called during render. To redirect in an event handler or similar, you can use `useRouter()` instead.' + : undefined + ); + } + + return basePermanentRedirect({...params, locale}, ...args); +} diff --git a/packages/next-intl/src/navigation/react-client/createLocalizedPathnamesNavigation.tsx b/packages/next-intl/src/navigation/react-client/createLocalizedPathnamesNavigation.tsx index 3d7b6105b..6b3848d40 100644 --- a/packages/next-intl/src/navigation/react-client/createLocalizedPathnamesNavigation.tsx +++ b/packages/next-intl/src/navigation/react-client/createLocalizedPathnamesNavigation.tsx @@ -14,6 +14,7 @@ import { HrefOrUrlObjectWithParams } from '../shared/utils'; import ClientLink from './ClientLink'; +import clientPermanentRedirect from './clientPermanentRedirect'; import clientRedirect from './clientRedirect'; import useBasePathname from './useBasePathname'; import useBaseRouter from './useBaseRouter'; @@ -89,6 +90,16 @@ export default function createLocalizedPathnamesNavigation< return clientRedirect({...opts, pathname: resolvedHref}, ...args); } + function permanentRedirect( + href: HrefOrHrefWithParams, + ...args: ParametersExceptFirst + ) { + // eslint-disable-next-line react-hooks/rules-of-hooks -- Reading from context here is fine, since `redirect` should be called during render + const locale = useTypedLocale(); + const resolvedHref = getPathname({href, locale}); + return clientPermanentRedirect({...opts, pathname: resolvedHref}, ...args); + } + function useRouter() { const baseRouter = useBaseRouter(); const defaultLocale = useTypedLocale(); @@ -156,6 +167,7 @@ export default function createLocalizedPathnamesNavigation< return { Link: LinkWithRef, redirect, + permanentRedirect, usePathname, useRouter, getPathname diff --git a/packages/next-intl/src/navigation/react-client/createSharedPathnamesNavigation.tsx b/packages/next-intl/src/navigation/react-client/createSharedPathnamesNavigation.tsx index d8dc7ab53..76f1cba7b 100644 --- a/packages/next-intl/src/navigation/react-client/createSharedPathnamesNavigation.tsx +++ b/packages/next-intl/src/navigation/react-client/createSharedPathnamesNavigation.tsx @@ -5,6 +5,7 @@ import { ParametersExceptFirst } from '../../shared/types'; import ClientLink from './ClientLink'; +import clientPermanentRedirect from './clientPermanentRedirect'; import clientRedirect from './clientRedirect'; import useBasePathname from './useBasePathname'; import useBaseRouter from './useBaseRouter'; @@ -37,6 +38,13 @@ export default function createSharedPathnamesNavigation< return clientRedirect({...opts, pathname}, ...args); } + function permanentRedirect( + pathname: string, + ...args: ParametersExceptFirst + ) { + return clientPermanentRedirect({...opts, pathname}, ...args); + } + function usePathname(): string { // @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 useBasePathname(); @@ -45,6 +53,7 @@ export default function createSharedPathnamesNavigation< return { Link: LinkWithRef, redirect, + permanentRedirect, usePathname, useRouter: useBaseRouter }; diff --git a/packages/next-intl/src/navigation/react-server/createLocalizedPathnamesNavigation.tsx b/packages/next-intl/src/navigation/react-server/createLocalizedPathnamesNavigation.tsx index 952b467b8..6e830d48b 100644 --- a/packages/next-intl/src/navigation/react-server/createLocalizedPathnamesNavigation.tsx +++ b/packages/next-intl/src/navigation/react-server/createLocalizedPathnamesNavigation.tsx @@ -13,6 +13,7 @@ import { normalizeNameOrNameWithParams } from '../shared/utils'; import ServerLink from './ServerLink'; +import serverPermanentRedirect from './serverPermanentRedirect'; import serverRedirect from './serverRedirect'; export default function createLocalizedPathnamesNavigation< @@ -68,6 +69,15 @@ export default function createLocalizedPathnamesNavigation< return serverRedirect({localePrefix, pathname}, ...args); } + function permanentRedirect( + href: HrefOrHrefWithParams, + ...args: ParametersExceptFirst + ) { + const locale = getRequestLocale(); + const pathname = getPathname({href, locale}); + return serverPermanentRedirect({localePrefix, pathname}, ...args); + } + function getPathname({ href, locale @@ -93,6 +103,7 @@ export default function createLocalizedPathnamesNavigation< 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 c7d923b1a..6d58c754b 100644 --- a/packages/next-intl/src/navigation/react-server/createSharedPathnamesNavigation.tsx +++ b/packages/next-intl/src/navigation/react-server/createSharedPathnamesNavigation.tsx @@ -5,6 +5,7 @@ import { ParametersExceptFirst } from '../../shared/types'; import ServerLink from './ServerLink'; +import serverPermanentRedirect from './serverPermanentRedirect'; import serverRedirect from './serverRedirect'; export default function createSharedPathnamesNavigation< @@ -29,9 +30,17 @@ export default function createSharedPathnamesNavigation< return serverRedirect({...opts, pathname}, ...args); } + function permanentRedirect( + pathname: string, + ...args: ParametersExceptFirst + ) { + return serverPermanentRedirect({...opts, pathname}, ...args); + } + return { Link, redirect, + permanentRedirect, usePathname: notSupported('usePathname'), useRouter: notSupported('useRouter') }; diff --git a/packages/next-intl/src/navigation/react-server/serverPermanentRedirect.tsx b/packages/next-intl/src/navigation/react-server/serverPermanentRedirect.tsx new file mode 100644 index 000000000..32386b0a6 --- /dev/null +++ b/packages/next-intl/src/navigation/react-server/serverPermanentRedirect.tsx @@ -0,0 +1,11 @@ +import {getRequestLocale} from '../../server/react-server/RequestLocale'; +import {LocalePrefix, ParametersExceptFirst} from '../../shared/types'; +import basePermanentRedirect from '../shared/basePermanentRedirect'; + +export default function serverPermanentRedirect( + params: {pathname: string; localePrefix?: LocalePrefix}, + ...args: ParametersExceptFirst +) { + const locale = getRequestLocale(); + return basePermanentRedirect({...params, locale}, ...args); +} diff --git a/packages/next-intl/src/navigation/shared/basePermanentRedirect.tsx b/packages/next-intl/src/navigation/shared/basePermanentRedirect.tsx new file mode 100644 index 000000000..e85053f8a --- /dev/null +++ b/packages/next-intl/src/navigation/shared/basePermanentRedirect.tsx @@ -0,0 +1,22 @@ +import {permanentRedirect as nextPermanentRedirect} from 'next/navigation'; +import { + AllLocales, + LocalePrefix, + ParametersExceptFirst +} from '../../shared/types'; +import {prefixPathname} from '../../shared/utils'; + +export default function basePermanentRedirect( + params: { + pathname: string; + locale: AllLocales[number]; + localePrefix?: LocalePrefix; + }, + ...args: ParametersExceptFirst +) { + const localizedPathname = + params.localePrefix === 'never' + ? params.pathname + : prefixPathname(params.locale, params.pathname); + return nextPermanentRedirect(localizedPathname, ...args); +} diff --git a/packages/next-intl/test/navigation/createLocalizedPathnamesNavigation.test.tsx b/packages/next-intl/test/navigation/createLocalizedPathnamesNavigation.test.tsx index a26d9f671..d1cd7748e 100644 --- a/packages/next-intl/test/navigation/createLocalizedPathnamesNavigation.test.tsx +++ b/packages/next-intl/test/navigation/createLocalizedPathnamesNavigation.test.tsx @@ -3,6 +3,7 @@ import { usePathname as useNextPathname, useParams, redirect as nextRedirect, + permanentRedirect as nextPermanentRedirect, RedirectType } from 'next/navigation'; import React from 'react'; @@ -20,7 +21,8 @@ vi.mock('next/navigation', async () => { ...actual, usePathname: vi.fn(), useParams: vi.fn(), - redirect: vi.fn() + redirect: vi.fn(), + permanentRedirect: vi.fn() }; }); vi.mock('next-intl/config', () => ({ @@ -112,11 +114,12 @@ describe.each([ }); describe("localePrefix: 'as-needed'", () => { - const {Link, getPathname, redirect} = createLocalizedPathnamesNavigation({ - locales, - pathnames, - localePrefix: 'as-needed' - }); + const {Link, getPathname, permanentRedirect, redirect} = + createLocalizedPathnamesNavigation({ + locales, + pathnames, + localePrefix: 'as-needed' + }); describe('Link', () => { it('renders a prefix for the default locale initially', () => { @@ -320,6 +323,104 @@ describe.each([ }); }); + describe('permanentRedirect', () => { + function Component({ + href + }: { + href: Parameters>[0]; + }) { + permanentRedirect(href); + return null; + } + + it('can permanently redirect for the default locale', () => { + vi.mocked(useNextPathname).mockImplementation(() => '/'); + const {rerender} = render(); + expect(nextPermanentRedirect).toHaveBeenLastCalledWith('/en'); + + rerender(); + expect(nextPermanentRedirect).toHaveBeenLastCalledWith('/en/about'); + + rerender( + + ); + expect(nextPermanentRedirect).toHaveBeenLastCalledWith( + '/en/news/launch-party-3' + ); + }); + + it('can permanently redirect for a non-default locale', () => { + vi.mocked(useParams).mockImplementation(() => ({locale: 'de'})); + vi.mocked(getRequestLocale).mockImplementation(() => 'de'); + vi.mocked(useNextPathname).mockImplementation(() => '/'); + + const {rerender} = render(); + expect(nextPermanentRedirect).toHaveBeenLastCalledWith('/de'); + + rerender(); + expect(nextPermanentRedirect).toHaveBeenLastCalledWith( + '/de/ueber-uns' + ); + + rerender( + + ); + expect(nextPermanentRedirect).toHaveBeenLastCalledWith( + '/de/neuigkeiten/launch-party-3' + ); + }); + + it('supports optional search params', () => { + vi.mocked(useNextPathname).mockImplementation(() => '/'); + render( + + ); + expect(nextPermanentRedirect).toHaveBeenLastCalledWith( + '/en?foo=bar&bar=1&bar=2' + ); + }); + + it('handles unknown routes', () => { + vi.mocked(useNextPathname).mockImplementation(() => '/'); + // @ts-expect-error -- Unknown route + render(); + expect(nextPermanentRedirect).toHaveBeenLastCalledWith('/en/unknown'); + }); + + it('can supply a type', () => { + function Test() { + permanentRedirect('/', RedirectType.push); + return null; + } + render(); + expect(nextPermanentRedirect).toHaveBeenLastCalledWith('/en', 'push'); + }); + }); + describe('getPathname', () => { it('resolves to the correct path', () => { expect( @@ -337,11 +438,12 @@ describe.each([ }); describe("localePrefix: 'never'", () => { - const {Link, redirect} = createLocalizedPathnamesNavigation({ - pathnames, - locales, - localePrefix: 'never' - }); + const {Link, permanentRedirect, redirect} = + createLocalizedPathnamesNavigation({ + pathnames, + locales, + localePrefix: 'never' + }); describe('Link', () => { it("doesn't render a prefix for the default locale", () => { @@ -443,6 +545,93 @@ describe.each([ expect(nextRedirect).toHaveBeenLastCalledWith('/unknown'); }); }); + + describe('permanentRedirect', () => { + function Component({ + href + }: { + href: Parameters>[0]; + }) { + permanentRedirect(href); + return null; + } + + it('can permanently redirect for the default locale', () => { + vi.mocked(useNextPathname).mockImplementation(() => '/'); + const {rerender} = render(); + expect(nextPermanentRedirect).toHaveBeenLastCalledWith('/'); + + rerender(); + expect(nextPermanentRedirect).toHaveBeenLastCalledWith('/about'); + + rerender( + + ); + expect(nextPermanentRedirect).toHaveBeenLastCalledWith( + '/news/launch-party-3' + ); + }); + + it('can permanently redirect for a non-default locale', () => { + vi.mocked(useParams).mockImplementation(() => ({locale: 'de'})); + vi.mocked(getRequestLocale).mockImplementation(() => 'de'); + vi.mocked(useNextPathname).mockImplementation(() => '/'); + + const {rerender} = render(); + expect(nextPermanentRedirect).toHaveBeenLastCalledWith('/'); + + rerender(); + expect(nextPermanentRedirect).toHaveBeenLastCalledWith('/ueber-uns'); + + rerender( + + ); + expect(nextPermanentRedirect).toHaveBeenLastCalledWith( + '/neuigkeiten/launch-party-3' + ); + }); + + it('supports optional search params', () => { + vi.mocked(useNextPathname).mockImplementation(() => '/'); + render( + + ); + expect(nextPermanentRedirect).toHaveBeenLastCalledWith( + '/?foo=bar&bar=1&bar=2' + ); + }); + + it('handles unknown routes', () => { + vi.mocked(useNextPathname).mockImplementation(() => '/'); + // @ts-expect-error -- Unknown route + render(); + expect(nextPermanentRedirect).toHaveBeenLastCalledWith('/unknown'); + }); + }); }); } ); diff --git a/packages/next-intl/test/navigation/createSharedPathnamesNavigation.test.tsx b/packages/next-intl/test/navigation/createSharedPathnamesNavigation.test.tsx index 5f3cb21a7..48d96037a 100644 --- a/packages/next-intl/test/navigation/createSharedPathnamesNavigation.test.tsx +++ b/packages/next-intl/test/navigation/createSharedPathnamesNavigation.test.tsx @@ -3,6 +3,7 @@ import { usePathname as useNextPathname, useParams, redirect as nextRedirect, + permanentRedirect as nextPermanentRedirect, RedirectType } from 'next/navigation'; import React from 'react'; @@ -19,7 +20,8 @@ vi.mock('next/navigation', async () => { ...actual, useParams: vi.fn(() => ({locale: 'en'})), usePathname: vi.fn(() => '/'), - redirect: vi.fn() + redirect: vi.fn(), + permanentRedirect: vi.fn() }; }); vi.mock('next-intl/config', () => ({ @@ -96,10 +98,11 @@ describe.each([ }); describe("localePrefix: 'as-needed'", () => { - const {Link, redirect} = createSharedPathnamesNavigation({ - locales, - localePrefix: 'as-needed' - }); + const {Link, permanentRedirect, redirect} = + createSharedPathnamesNavigation({ + locales, + localePrefix: 'as-needed' + }); describe('Link', () => { it('renders a prefix for the default locale initially', () => { @@ -178,13 +181,69 @@ describe.each([ expect(nextRedirect).toHaveBeenLastCalledWith('/en', 'push'); }); }); + + describe('permanentRedirect', () => { + function Component({href}: {href: string}) { + permanentRedirect(href); + return null; + } + + it('can permanently redirect for the default locale', () => { + vi.mocked(useNextPathname).mockImplementation(() => '/'); + const {rerender} = render(); + expect(nextPermanentRedirect).toHaveBeenLastCalledWith('/en'); + + rerender(); + expect(nextPermanentRedirect).toHaveBeenLastCalledWith('/en/about'); + + rerender(); + expect(nextPermanentRedirect).toHaveBeenLastCalledWith( + '/en/news/launch-party-3' + ); + }); + + it('can permanently redirect for a non-default locale', () => { + vi.mocked(useParams).mockImplementation(() => ({locale: 'de'})); + vi.mocked(getRequestLocale).mockImplementation(() => 'de'); + + vi.mocked(useNextPathname).mockImplementation(() => '/'); + const {rerender} = render(); + expect(nextPermanentRedirect).toHaveBeenLastCalledWith('/de'); + + rerender(); + expect(nextPermanentRedirect).toHaveBeenLastCalledWith('/de/about'); + + rerender(); + expect(nextPermanentRedirect).toHaveBeenLastCalledWith( + '/de/news/launch-party-3' + ); + }); + + it('supports optional search params', () => { + vi.mocked(useNextPathname).mockImplementation(() => '/'); + render(); + expect(nextPermanentRedirect).toHaveBeenLastCalledWith( + '/en?foo=bar&bar=1&bar=2' + ); + }); + + it('can supply a type', () => { + function Test() { + permanentRedirect('/', RedirectType.push); + return null; + } + render(); + expect(nextPermanentRedirect).toHaveBeenLastCalledWith('/en', 'push'); + }); + }); }); describe("localePrefix: 'never'", () => { - const {Link, redirect} = createSharedPathnamesNavigation({ - locales, - localePrefix: 'never' - }); + const {Link, permanentRedirect, redirect} = + createSharedPathnamesNavigation({ + locales, + localePrefix: 'never' + }); describe('Link', () => { it("doesn't render a prefix for the default locale", () => { @@ -235,6 +294,44 @@ describe.each([ expect(nextRedirect).toHaveBeenLastCalledWith('/news/launch-party-3'); }); }); + + describe('permanentRedirect', () => { + function Component({href}: {href: string}) { + permanentRedirect(href); + return null; + } + + it('can permanently redirect for the default locale', () => { + vi.mocked(useNextPathname).mockImplementation(() => '/'); + const {rerender} = render(); + expect(nextPermanentRedirect).toHaveBeenLastCalledWith('/'); + + rerender(); + expect(nextPermanentRedirect).toHaveBeenLastCalledWith('/about'); + + rerender(); + expect(nextPermanentRedirect).toHaveBeenLastCalledWith( + '/news/launch-party-3' + ); + }); + + it('can permanently redirect for a non-default locale', () => { + vi.mocked(useParams).mockImplementation(() => ({locale: 'de'})); + vi.mocked(getRequestLocale).mockImplementation(() => 'de'); + + vi.mocked(useNextPathname).mockImplementation(() => '/'); + const {rerender} = render(); + expect(nextPermanentRedirect).toHaveBeenLastCalledWith('/'); + + rerender(); + expect(nextPermanentRedirect).toHaveBeenLastCalledWith('/about'); + + rerender(); + expect(nextPermanentRedirect).toHaveBeenLastCalledWith( + '/news/launch-party-3' + ); + }); + }); }); describe('usage without statically known locales', () => {