Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Add localePrefix for navigation APIs for an improved initial render of Link when using localePrefix: never. Also fix edge case in middleware when using localized pathnames for redirects that remove a locale prefix (fixes an infinite loop). #678

Merged
merged 10 commits into from
Nov 29, 2023
1 change: 1 addition & 0 deletions packages/next-intl/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,7 @@
"@types/negotiator": "^0.6.1",
"@types/node": "^17.0.23",
"@types/react": "18.2.34",
"@types/react-dom": "^18.2.17",
"eslint": "^8.54.0",
"eslint-config-molindo": "^7.0.0",
"eslint-plugin-deprecation": "^1.4.1",
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,4 @@
import {AllLocales, Pathnames} from '../shared/types';

type LocalePrefix = 'as-needed' | 'always' | 'never';
import {AllLocales, LocalePrefix, Pathnames} from '../shared/types';

type RoutingBaseConfig<Locales extends AllLocales> = {
/** A list of all locales that are supported. */
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
import React, {ComponentProps, ReactElement, forwardRef} from 'react';
import useLocale from '../../react-client/useLocale';
import {AllLocales, ParametersExceptFirst, Pathnames} from '../../shared/types';
import {
AllLocales,
LocalePrefix,
ParametersExceptFirst,
Pathnames
} from '../../shared/types';
import {
compileLocalizedPathname,
getRoute,
Expand All @@ -16,7 +21,15 @@ import useBaseRouter from './useBaseRouter';
export default function createLocalizedPathnamesNavigation<
Locales extends AllLocales,
PathnamesConfig extends Pathnames<Locales>
>({locales, pathnames}: {locales: Locales; pathnames: PathnamesConfig}) {
>({
localePrefix,
locales,
pathnames
}: {
locales: Locales;
pathnames: PathnamesConfig;
localePrefix?: LocalePrefix;
}) {
function useTypedLocale(): (typeof locales)[number] {
const locale = useLocale();
const isValid = locales.includes(locale as any);
Expand Down Expand Up @@ -56,6 +69,7 @@ export default function createLocalizedPathnamesNavigation<
pathnames
})}
locale={locale}
localePrefix={localePrefix}
{...rest}
/>
);
Expand Down
Original file line number Diff line number Diff line change
@@ -1,15 +1,33 @@
import {AllLocales} from '../../shared/types';
import React, {ComponentProps, ReactElement, forwardRef} from 'react';
import {AllLocales, LocalePrefix} from '../../shared/types';
import BaseLink from './BaseLink';
import baseRedirect from './baseRedirect';
import useBasePathname from './useBasePathname';
import useBaseRouter from './useBaseRouter';

export default function createSharedPathnamesNavigation<
Locales extends AllLocales
// eslint-disable-next-line @typescript-eslint/no-unused-vars -- The value is not used yet, only the type information is important
>(opts: {locales: Locales}) {
>(opts: {locales: Locales; localePrefix?: LocalePrefix}) {
type LinkProps = Omit<
ComponentProps<typeof BaseLink<Locales>>,
'localePrefix'
>;
function Link(props: LinkProps, ref: LinkProps['ref']) {
return (
<BaseLink<Locales>
ref={ref}
localePrefix={opts.localePrefix}
{...props}
/>
);
}
const LinkWithRef = forwardRef(Link) as (
props: LinkProps & {ref?: LinkProps['ref']}
) => ReactElement;
(LinkWithRef as any).displayName = 'Link';

return {
Link: BaseLink as typeof BaseLink<Locales>,
Link: LinkWithRef,
redirect: baseRedirect,
usePathname: useBasePathname,
useRouter: useBaseRouter
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
import React, {ComponentProps} from 'react';
import {getRequestLocale} from '../../server/RequestLocale';
import {AllLocales, ParametersExceptFirst, Pathnames} from '../../shared/types';
import {
AllLocales,
LocalePrefix,
ParametersExceptFirst,
Pathnames
} from '../../shared/types';
import {
HrefOrHrefWithParams,
HrefOrUrlObjectWithParams,
Expand All @@ -13,7 +18,15 @@ import baseRedirect from './baseRedirect';
export default function createLocalizedPathnamesNavigation<
Locales extends AllLocales,
PathnamesConfig extends Pathnames<Locales>
>({locales, pathnames}: {locales: Locales; pathnames: Pathnames<Locales>}) {
>({
localePrefix,
locales,
pathnames
}: {
locales: Locales;
pathnames: Pathnames<Locales>;
localePrefix?: LocalePrefix;
}) {
type LinkProps<Pathname extends keyof PathnamesConfig> = Omit<
ComponentProps<typeof BaseLink>,
'href' | 'name'
Expand All @@ -40,6 +53,7 @@ export default function createLocalizedPathnamesNavigation<
pathnames
})}
locale={locale}
localePrefix={localePrefix}
{...rest}
/>
);
Expand Down
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
import {AllLocales} from '../../shared/types';
import React, {ComponentProps} from 'react';
import {AllLocales, LocalePrefix} from '../../shared/types';
import BaseLink from './BaseLink';
import baseRedirect from './baseRedirect';

export default function createSharedPathnamesNavigation<
Locales extends AllLocales
// eslint-disable-next-line @typescript-eslint/no-unused-vars -- The value is not used yet, only the type information is important
>(opts: {locales: Locales}) {
>(opts: {locales: Locales; localePrefix?: LocalePrefix}) {
function notSupported(message: string) {
return () => {
throw new Error(
Expand All @@ -14,8 +14,12 @@ export default function createSharedPathnamesNavigation<
};
}

function Link(props: ComponentProps<typeof BaseLink<Locales>>) {
return <BaseLink<Locales> localePrefix={opts.localePrefix} {...props} />;
}

return {
Link: BaseLink<Locales>,
Link,
redirect: baseRedirect,
usePathname: notSupported('usePathname'),
useRouter: notSupported('useRouter')
Expand Down
8 changes: 4 additions & 4 deletions packages/next-intl/src/navigation/shared/StrictParams.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,16 +11,16 @@ type ReadUntil<Path> = Path extends `${infer Match}]${infer Rest}`
type RemovePrefixes<Key> = Key extends `[...${infer Name}`
? Name
: Key extends `...${infer Name}`
? Name
: Key;
? Name
: Key;

type StrictParams<Pathname> = Pathname extends `${string}[${string}`
? {
[Key in ReadFrom<Pathname>[number] as RemovePrefixes<Key>]: Key extends `[...${string}`
? Array<ParamValue> | undefined
: Key extends `...${string}`
? Array<ParamValue>
: ParamValue;
? Array<ParamValue>
: ParamValue;
}
: never;

Expand Down
8 changes: 4 additions & 4 deletions packages/next-intl/src/navigation/shared/utils.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,10 @@ type HrefOrHrefWithParamsImpl<Pathname, Other> =
? // Optional catch-all
Pathname | ({pathname: Pathname; params?: StrictParams<Pathname>} & Other)
: Pathname extends `${string}[${string}`
? // Required catch-all & regular params
{pathname: Pathname; params: StrictParams<Pathname>} & Other
: // No params
Pathname | ({pathname: Pathname} & Other);
? // Required catch-all & regular params
{pathname: Pathname; params: StrictParams<Pathname>} & Other
: // No params
Pathname | ({pathname: Pathname} & Other);

export type HrefOrUrlObjectWithParams<Pathname> = HrefOrHrefWithParamsImpl<
Pathname,
Expand Down
22 changes: 13 additions & 9 deletions packages/next-intl/src/shared/BaseLinkWithLocale.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,16 @@ import NextLink from 'next/link';
import {usePathname} from 'next/navigation';
import React, {ComponentProps, forwardRef, useEffect, useState} from 'react';
import useLocale from '../react-client/useLocale';
import {LocalePrefix} from './types';
import {isLocalHref, localizeHref, prefixHref} from './utils';

type Props = Omit<ComponentProps<typeof NextLink>, 'locale'> & {
locale: string;
localePrefix?: LocalePrefix;
};

function BaseLinkWithLocale(
{href, locale, prefetch, ...rest}: Props,
{href, locale, localePrefix, prefetch, ...rest}: Props,
ref: Props['ref']
) {
// The types aren't entirely correct here. Outside of Next.js
Expand All @@ -22,15 +24,17 @@ function BaseLinkWithLocale(
const isChangingLocale = locale !== defaultLocale;

const [localizedHref, setLocalizedHref] = useState<typeof href>(() =>
isLocalHref(href) && locale
? // Potentially the href shouldn't be prefixed, but to determine this we
isLocalHref(href) && (localePrefix !== 'never' || isChangingLocale)
? // For the `localePrefix: 'as-necessary' strategy, the href shouldn't
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
? // For the `localePrefix: 'as-necessary' strategy, the href shouldn't
? // For the `localePrefix: 'as-needed' strategy, the href shouldn't

Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch, thanks @A7med3bdulBaset!

// be prefixed if the locale is the default locale. To termine this, we
// need a) the default locale and b) the information if we use prefixed
// routing. During the server side render (both in RSC as well as SSR),
// we don't have this information. Therefore we always prefix the href
// since this will always result in a valid URL, even if it might cause
// a redirect. This is better than pointing to a non-localized href
// during the server render, which would potentially be wrong. The final
// href is determined in the effect below.
// routing. The default locale can vary by domain, therefore during the
// RSC as well as the SSR render, we can't determine the default locale
// statically. Therefore we always prefix the href since this will
// always result in a valid URL, even if it might cause a redirect. This
// is better than pointing to a non-localized href during the server
// render, which would potentially be wrong. The final href is
// determined in the effect below.
prefixHref(href, locale)
: href
);
Expand Down
2 changes: 2 additions & 0 deletions packages/next-intl/src/shared/types.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
export type AllLocales = ReadonlyArray<string>;

export type LocalePrefix = 'as-needed' | 'always' | 'never';

export type Pathnames<Locales extends AllLocales> = Record<
string,
{[Key in Locales[number]]: string} | string
Expand Down
Loading
Loading