Skip to content

Commit

Permalink
feat: Add localePrefix for navigation APIs for an improved initial …
Browse files Browse the repository at this point in the history
…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). (amannn#678)

By accepting an optional `localePrefix` for the navigation APIs, we can
get the initial render of the `href` of `Link` right if `localePrefix: 'never'` is set. This can be helpful if domain-based routing is used and
you have a single locale per domain.

Note that this change is backward-compatible. It's now recommended to
set the `localePrefix` for the navigation APIs to get improved behavior
for `Link` in case `localePrefix: 'never'` is used, but otherwise your
app will keep working with the previous behavior.





Ref amannn#444
  • Loading branch information
amannn authored Nov 29, 2023
1 parent 2a90509 commit 1dae3ff
Show file tree
Hide file tree
Showing 26 changed files with 1,325 additions and 690 deletions.
5 changes: 3 additions & 2 deletions 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 All @@ -110,11 +111,11 @@
},
{
"path": "dist/production/navigation.react-client.js",
"limit": "2.6 KB"
"limit": "2.62 KB"
},
{
"path": "dist/production/navigation.react-server.js",
"limit": "2.75 KB"
"limit": "2.8 KB"
},
{
"path": "dist/production/server.js",
Expand Down
4 changes: 1 addition & 3 deletions src/middleware/NextIntlMiddlewareConfig.tsx
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
15 changes: 9 additions & 6 deletions src/middleware/middleware.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -178,13 +178,16 @@ export default function createMiddleware<Locales extends AllLocales>(
response = redirect(pathWithSearch);
}
} else {
const pathWithSearch = getPathWithSearch(
const internalPathWithSearch = getPathWithSearch(
pathname,
request.nextUrl.search
);

if (hasLocalePrefix) {
const basePath = getBasePath(pathWithSearch, pathLocale);
const basePath = getBasePath(
getPathWithSearch(normalizedPathname, request.nextUrl.search),
pathLocale
);

if (configWithDefaults.localePrefix === 'never') {
response = redirect(basePath);
Expand All @@ -205,10 +208,10 @@ export default function createMiddleware<Locales extends AllLocales>(
if (domain?.domain !== pathDomain?.domain && !hasUnknownHost) {
response = redirect(basePath, pathDomain?.domain);
} else {
response = rewrite(pathWithSearch);
response = rewrite(internalPathWithSearch);
}
} else {
response = rewrite(pathWithSearch);
response = rewrite(internalPathWithSearch);
}
}
} else {
Expand All @@ -221,9 +224,9 @@ export default function createMiddleware<Locales extends AllLocales>(
(configWithDefaults.localePrefix === 'as-needed' ||
configWithDefaults.domains))
) {
response = rewrite(`/${locale}${pathWithSearch}`);
response = rewrite(`/${locale}${internalPathWithSearch}`);
} else {
response = redirect(`/${locale}${pathWithSearch}`);
response = redirect(`/${locale}${internalPathWithSearch}`);
}
}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,28 +1,23 @@
import React, {ComponentProps, ReactElement, forwardRef} from 'react';
import useLocale from '../../react-client/useLocale';
import BaseLinkWithLocale from '../../shared/BaseLinkWithLocale';
import {AllLocales} from '../../shared/types';
import BaseLink from '../shared/BaseLink';

type Props<Locales extends AllLocales> = Omit<
ComponentProps<typeof BaseLinkWithLocale>,
ComponentProps<typeof BaseLink>,
'locale'
> & {
locale?: Locales[number];
};

function BaseLink<Locales extends AllLocales>(
function ClientLink<Locales extends AllLocales>(
{locale, ...rest}: Props<Locales>,
ref: Props<Locales>['ref']
) {
const defaultLocale = useLocale();
const linkLocale = locale || defaultLocale;
return (
<BaseLinkWithLocale
ref={ref}
hrefLang={linkLocale}
locale={linkLocale}
{...rest}
/>
<BaseLink ref={ref} hrefLang={linkLocale} locale={linkLocale} {...rest} />
);
}

Expand All @@ -46,8 +41,10 @@ function BaseLink<Locales extends AllLocales>(
* the `set-cookie` response header would cause the locale cookie on the current
* page to be overwritten before the user even decides to change the locale.
*/
const BaseLinkWithRef = forwardRef(BaseLink) as <Locales extends AllLocales>(
const ClientLinkWithRef = forwardRef(ClientLink) as <
Locales extends AllLocales
>(
props: Props<Locales> & {ref?: Props<Locales>['ref']}
) => ReactElement;
(BaseLinkWithRef as any).displayName = 'Link';
export default BaseLinkWithRef;
(ClientLinkWithRef as any).displayName = 'ClientLink';
export default ClientLinkWithRef;
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
import useLocale from '../../react-client/useLocale';
import redirectWithLocale from '../../shared/redirectWithLocale';
import {ParametersExceptFirstTwo} from '../../shared/types';
import {LocalePrefix, ParametersExceptFirstTwo} from '../../shared/types';
import baseRedirect from '../shared/baseRedirect';

export default function baseRedirect(
pathname: string,
...args: ParametersExceptFirstTwo<typeof redirectWithLocale>
export default function clientRedirect(
params: {localePrefix?: LocalePrefix; pathname: string},
...args: ParametersExceptFirstTwo<typeof baseRedirect>
) {
let locale;
try {
Expand All @@ -18,5 +18,5 @@ export default function baseRedirect(
);
}

return redirectWithLocale(pathname, locale, ...args);
return baseRedirect({...params, locale}, ...args);
}
42 changes: 27 additions & 15 deletions src/navigation/react-client/createLocalizedPathnamesNavigation.tsx
Original file line number Diff line number Diff line change
@@ -1,25 +1,34 @@
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,
normalizeNameOrNameWithParams,
HrefOrHrefWithParams,
HrefOrUrlObjectWithParams
} from '../shared/utils';
import BaseLink from './BaseLink';
import baseRedirect from './baseRedirect';
import ClientLink from './ClientLink';
import clientRedirect from './clientRedirect';
import useBasePathname from './useBasePathname';
import useBaseRouter from './useBaseRouter';

export default function createLocalizedPathnamesNavigation<
Locales extends AllLocales,
PathnamesConfig extends Pathnames<Locales>
>({locales, pathnames}: {locales: Locales; pathnames: PathnamesConfig}) {
function useTypedLocale(): (typeof locales)[number] {
>(opts: {
locales: Locales;
pathnames: PathnamesConfig;
localePrefix?: LocalePrefix;
}) {
function useTypedLocale(): (typeof opts.locales)[number] {
const locale = useLocale();
const isValid = locales.includes(locale as any);
const isValid = opts.locales.includes(locale as any);
if (!isValid) {
throw new Error(
process.env.NODE_ENV !== 'production'
Expand All @@ -31,50 +40,53 @@ export default function createLocalizedPathnamesNavigation<
}

type LinkProps<Pathname extends keyof PathnamesConfig> = Omit<
ComponentProps<typeof BaseLink>,
ComponentProps<typeof ClientLink>,
'href' | 'name'
> & {
href: HrefOrUrlObjectWithParams<Pathname>;
locale?: Locales[number];
};
function Link<Pathname extends keyof PathnamesConfig>(
{href, locale, ...rest}: LinkProps<Pathname>,
ref?: ComponentProps<typeof BaseLink>['ref']
ref?: ComponentProps<typeof ClientLink>['ref']
) {
const defaultLocale = useTypedLocale();
const finalLocale = locale || defaultLocale;

return (
<BaseLink
<ClientLink
ref={ref}
href={compileLocalizedPathname<Locales, Pathname>({
locale: finalLocale,
// @ts-expect-error -- This is ok
pathname: href,
// @ts-expect-error -- This is ok
params: typeof href === 'object' ? href.params : undefined,
pathnames
pathnames: opts.pathnames
})}
locale={locale}
localePrefix={opts.localePrefix}
{...rest}
/>
);
}
const LinkWithRef = forwardRef(Link) as unknown as <
Pathname extends keyof PathnamesConfig
>(
props: LinkProps<Pathname> & {ref?: ComponentProps<typeof BaseLink>['ref']}
props: LinkProps<Pathname> & {
ref?: ComponentProps<typeof ClientLink>['ref'];
}
) => ReactElement;
(LinkWithRef as any).displayName = 'Link';

function redirect<Pathname extends keyof PathnamesConfig>(
href: HrefOrHrefWithParams<Pathname>,
...args: ParametersExceptFirst<typeof baseRedirect>
...args: ParametersExceptFirst<typeof clientRedirect>
) {
// eslint-disable-next-line react-hooks/rules-of-hooks -- Reading from context here is fine, since `redirect` should be called during render
const locale = useTypedLocale();
const resolvedHref = getPathname({href, locale});
return baseRedirect(resolvedHref, ...args);
return clientRedirect({...opts, pathname: resolvedHref}, ...args);
}

function useRouter() {
Expand Down Expand Up @@ -121,7 +133,7 @@ export default function createLocalizedPathnamesNavigation<
function usePathname(): keyof PathnamesConfig {
const pathname = useBasePathname();
const locale = useTypedLocale();
return getRoute({pathname, locale, pathnames});
return getRoute({pathname, locale, pathnames: opts.pathnames});
}

function getPathname({
Expand All @@ -134,7 +146,7 @@ export default function createLocalizedPathnamesNavigation<
return compileLocalizedPathname({
...normalizeNameOrNameWithParams(href),
locale,
pathnames
pathnames: opts.pathnames
});
}

Expand Down
43 changes: 36 additions & 7 deletions src/navigation/react-client/createSharedPathnamesNavigation.tsx
Original file line number Diff line number Diff line change
@@ -1,16 +1,45 @@
import {AllLocales} from '../../shared/types';
import BaseLink from './BaseLink';
import baseRedirect from './baseRedirect';
import React, {ComponentProps, ReactElement, forwardRef} from 'react';
import {
AllLocales,
LocalePrefix,
ParametersExceptFirst
} from '../../shared/types';
import ClientLink from './ClientLink';
import clientRedirect from './clientRedirect';
import useBasePathname from './useBasePathname';
import useBaseRouter from './useBaseRouter';

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

function redirect(
pathname: string,
...args: ParametersExceptFirst<typeof clientRedirect>
) {
return clientRedirect({...opts, pathname}, ...args);
}

return {
Link: BaseLink as typeof BaseLink<Locales>,
redirect: baseRedirect,
Link: LinkWithRef,
redirect,
usePathname: useBasePathname,
useRouter: useBaseRouter
};
Expand Down
Original file line number Diff line number Diff line change
@@ -1,20 +1,18 @@
import React, {ComponentProps} from 'react';
import {getLocale} from '../../server';
import BaseLinkWithLocale from '../../shared/BaseLinkWithLocale';
import {AllLocales} from '../../shared/types';
import BaseLink from '../shared/BaseLink';

type Props<Locales extends AllLocales> = Omit<
ComponentProps<typeof BaseLinkWithLocale>,
ComponentProps<typeof BaseLink>,
'locale'
> & {
locale?: Locales[number];
};

export default async function BaseLink<Locales extends AllLocales>({
export default async function ServerLink<Locales extends AllLocales>({
locale,
...rest
}: Props<Locales>) {
return (
<BaseLinkWithLocale locale={locale || (await getLocale())} {...rest} />
);
return <BaseLink locale={locale || (await getLocale())} {...rest} />;
}
11 changes: 0 additions & 11 deletions src/navigation/react-server/baseRedirect.tsx

This file was deleted.

Loading

0 comments on commit 1dae3ff

Please sign in to comment.