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: Automatically prefer localized pathnames that are more specific #983

Merged
Merged
Show file tree
Hide file tree
Changes from 6 commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
2550e48
Ensure more specific localized pathnames are preferred
fkapsahili Apr 6, 2024
8fa7d7e
Make sure that static pathnames are prioritized over optional catch-a…
fkapsahili Apr 6, 2024
012de6c
Update docs to reflect changes in route prioritization
fkapsahili Apr 6, 2024
1837b98
Increase middleware size limit
fkapsahili Apr 6, 2024
752f01e
Add playwright tests
fkapsahili Apr 6, 2024
3d4d3a6
Update middleware docs
fkapsahili Apr 8, 2024
cd438a9
Make sure prioritization works for nested dynamic routes
fkapsahili Apr 13, 2024
8dd2573
Merge branch 'main' into fix/automatically-prefer-localized-pathnames
fkapsahili Apr 13, 2024
40c994a
Increase middleware size limit
fkapsahili Apr 13, 2024
bc659c9
Merge branch 'fix/automatically-prefer-localized-pathnames' of https:…
fkapsahili Apr 13, 2024
f7a8bfb
Increase middleware size limit
fkapsahili Apr 13, 2024
d13fcf3
Merge branch 'main' into fix/automatically-prefer-localized-pathnames
fkapsahili Apr 17, 2024
0efe8a2
Increase middleware size limit
fkapsahili Apr 17, 2024
1f00897
Update packages/next-intl/src/middleware/utils.tsx
fkapsahili Apr 22, 2024
effc0e6
Update packages/next-intl/src/middleware/utils.tsx
fkapsahili Apr 22, 2024
961c61f
Update packages/next-intl/test/middleware/middleware.test.tsx
fkapsahili Apr 22, 2024
9e9fd75
Update packages/next-intl/test/middleware/middleware.test.tsx
fkapsahili Apr 22, 2024
d983757
Update packages/next-intl/test/middleware/middleware.test.tsx
fkapsahili Apr 22, 2024
2708b17
Update packages/next-intl/test/middleware/middleware.test.tsx
fkapsahili Apr 22, 2024
686882a
Merge branch 'main' into fix/automatically-prefer-localized-pathnames
fkapsahili Apr 22, 2024
bad1728
Update packages/next-intl/test/middleware/utils.test.tsx
fkapsahili Apr 22, 2024
f73628a
Fix misspellings
fkapsahili Apr 22, 2024
3f3acb4
Simplify pathname sorting function
fkapsahili Apr 22, 2024
59561c2
Fix misspellings in playwright tests
fkapsahili Apr 23, 2024
635a4bf
Rename new functions to indicate we're operating on segments (e.g. `i…
amannn Apr 26, 2024
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
15 changes: 7 additions & 8 deletions docs/pages/docs/routing/middleware.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -322,20 +322,19 @@ export default createMiddleware({
de: '/ueber-uns'
},

// Pathnames that overlap with dynamic segments should
// be listed first in the object, as the middleware will
// pick the first matching entry.
'/news/just-in': {
en: '/news/just-in',
de: '/neuigkeiten/aktuell'
},

// Dynamic params are supported via square brackets
'/news/[articleSlug]-[articleId]': {
en: '/news/[articleSlug]-[articleId]',
de: '/neuigkeiten/[articleSlug]-[articleId]'
},

// Static pathnames that overlap with dynamic segments
// will be prioritized over the dynamic segment
'/news/just-in': {
en: '/news/just-in',
de: '/neuigkeiten/aktuell'
},

// Also (optional) catch-all segments are supported
'/categories/[...slug]': {
en: '/categories/[...slug]',
Expand Down
3 changes: 3 additions & 0 deletions examples/example-app-router-playground/messages/de.json
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,9 @@
"NewsArticle": {
"title": "News-Artikel #{articleId}"
},
"JustIn": {
"title": "Gerade eingetroffen"
},
"NotFound": {
"title": "Diese Seite wurde nicht gefunden (404)"
},
Expand Down
3 changes: 3 additions & 0 deletions examples/example-app-router-playground/messages/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,9 @@
"NewsArticle": {
"title": "News article #{articleId}"
},
"JustIn": {
"title": "Just in"
},
"NotFound": {
"title": "This page was not found (404)"
},
Expand Down
3 changes: 3 additions & 0 deletions examples/example-app-router-playground/messages/es.json
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,9 @@
"NewsArticle": {
"title": "Noticias #{articleId}"
},
"JustIn": {
"title": "Recién llegado"
},
"NotFound": {
"title": "Esta página no se encontró (404)"
},
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import {useTranslations} from 'next-intl';

export default function NewsArticle() {
const t = useTranslations('JustIn');
return <h1>{t('title')}</h1>;
}
6 changes: 6 additions & 0 deletions examples/example-app-router-playground/src/navigation.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,12 @@ export const pathnames = {
de: '/neuigkeiten/[articleId]',
es: '/noticias/[articleId]',
ja: '/ニュース/[articleId]'
},
'/news/just-in': {
en: '/news/just-in',
de: '/neuigkeiten/aktuell',
es: '/noticias/justo-en',
ja: '/ニュース/現在'
}
} satisfies Pathnames<typeof locales>;

Expand Down
18 changes: 18 additions & 0 deletions examples/example-app-router-playground/tests/main.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,24 @@ it('redirects unprefixed paths for non-default locales', async ({browser}) => {
page.getByRole('heading', {name: 'Verschachtelt'});
});

it('priotizes static routes over dynamic routes for the default locale', async ({
page
}) => {
await page.goto('/news/just-in');
await expect(page).toHaveURL('/news/just-in');
await expect(page.getByRole('heading', {name: 'Just In'})).toBeVisible();
});

it('priotizes static routes over dynamic routes for non-default locales', async ({
page
}) => {
await page.goto('/de/neuigkeiten/aktuell');
await expect(page).toHaveURL('/de/neuigkeiten/aktuell');
await expect(
page.getByRole('heading', {name: 'Gerade eingetroffen'})
).toBeVisible();
});

it('sets the `path` for the cookie', async ({page}) => {
await page.goto('/de/client');

Expand Down
2 changes: 1 addition & 1 deletion packages/next-intl/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -138,7 +138,7 @@
},
{
"path": "dist/production/middleware.js",
"limit": "5.95 KB"
"limit": "6.10 KB"
}
]
}
74 changes: 71 additions & 3 deletions packages/next-intl/src/middleware/utils.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,71 @@ export function getFirstPathnameSegment(pathname: string) {
return pathname.split('/')[1];
}

function getExternalPath<Locales extends AllLocales>(
path: string | {[Key in Locales[number]]: string}
): string {
const firstLocalePath = Object.values(path)[0];
return typeof path === 'string' ? path : firstLocalePath;
}

function isCatchAllRoute(pathname: string) {
return isOptionalCatchAll(pathname) || isCatchAll(pathname);
}

function isOptionalCatchAll(pathname: string) {
return pathname.includes('[[...');
}

function isCatchAll(pathname: string) {
return pathname.includes('[...');
}

function isDynamicRoute(pathname: string) {
return pathname.includes('[');
}

type PathnamePair<Locales extends AllLocales> = [
string,
string | {[Key in Locales[number]]: string}
];

export function comparePathnamePairs<Locales extends AllLocales>(
a: PathnamePair<Locales>,
b: PathnamePair<Locales>
): number {
const pathA = getExternalPath(a[1]);
const pathB = getExternalPath(b[1]);

const dynamicA = isDynamicRoute(pathA);
const dynamicB = isDynamicRoute(pathB);
const catchAllA = isCatchAllRoute(pathA);
const catchAllB = isCatchAllRoute(pathB);
Copy link
Owner

Choose a reason for hiding this comment

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

Diving a bit deeper into this, I'm not sure these flags are sufficient to be honest.

E.g. consider this case:

/articles/[category]/[articleSlug]
/articles/[category]/just-in

In this case, the routes would be considered equal in regard to priority, and probably the natural ordering would be preserved. However, the second route is more specific and should match.

The number of dynamic segments could be another indicator, but I think this won't solve the problem cleanly as e.g. /foo/[d1] and /foo/bar/[d1] have the same number of segments.

It seems like to be able to tell which route is really more specific, we have to consider routes in a hierarchical way, e.g. find the most specific route in /articles, then try to find the most specific route in ./[category], etc.

Do you by chance know if Next.js handles these cases correctly? Maybe we can take some inspiration from them.

What do you think?

Copy link
Owner

Choose a reason for hiding this comment

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

Or maybe the number of dynamic segments could in fact suffice? 🤔

In the example /foo/[d1] and /foo/bar/[d1], the routes have the same priority, but only one of them matches eventually—so maybe this isn't an issue.

Do you think you could a few more tests for edge cases so we can gain some clarity about those?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Ah yes, thanks for the hint! You were absolutely right, my solution didn't take into account the case where we have nested dynamic routes, e.g: /articles/[category]/[slug] and a static route in the same place 😅.

According to my tests, Next.js handles these cases correctly but I'm not very familiar with the Next.js repo itself, but at first glance I believe the code you referenced in the corresponding issue is actually where Next does the route sorting:
https://github.com/vercel/next.js/blob/415cd74b9a220b6f50da64da68c13043e9b02995/packages/next/src/shared/lib/router/utils/sorted-routes.ts

When looking at Next's code I realized that it is necessary that the individual route segments are compared with each other and have now implemented this additionally so that the failed tests that I added at the beginning of this update now pass.

If there are any other edge cases you can think of, I'm happy to add more tests! 🙂


// Prioritize static routes over dynamic and catch-all routes
if (!dynamicA && dynamicB) return -1;
if (dynamicA && !dynamicB) return 1;

// Both paths are either dynamic or static, prioritize non-catch-all over catch-all
if (catchAllA && !catchAllB) return 1;
if (!catchAllA && catchAllB) return -1;

// If both are the same type (static, dynamic non-catch-all, or catch-all), prioritize by depth
const depthA = pathA.split('/').length;
const depthB = pathB.split('/').length;

// Deeper (more specific) paths first
if (depthA !== depthB) return depthB - depthA;

return 0;
}

export function getSortedPathnames<Locales extends AllLocales>(
pathnames: NonNullable<MiddlewareConfigWithDefaults<Locales>['pathnames']>
) {
const sortedPathnames = Object.entries(pathnames).sort(comparePathnamePairs);
return sortedPathnames;
}

export function getInternalTemplate<
Locales extends AllLocales,
Pathnames extends NonNullable<
Expand All @@ -19,10 +84,13 @@ export function getInternalTemplate<
pathname: string,
locale: Locales[number]
): [Locales[number] | undefined, keyof Pathnames | undefined] {
// Sort pathnames by specificity
const sortedPathnames = getSortedPathnames(pathnames);
fkapsahili marked this conversation as resolved.
Show resolved Hide resolved
// Try to find a localized pathname that matches
for (const [internalPathname, localizedPathnamesOrPathname] of Object.entries(
pathnames
)) {
for (const [
internalPathname,
localizedPathnamesOrPathname
] of sortedPathnames) {
if (typeof localizedPathnamesOrPathname === 'string') {
const localizedPathname = localizedPathnamesOrPathname;
if (matchesPathname(localizedPathname, pathname)) {
Expand Down
92 changes: 91 additions & 1 deletion packages/next-intl/test/middleware/middleware.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -397,11 +397,29 @@ describe('prefix-based routing', () => {
'de-AT': '/produkte/[...slug]',
ja: '/製品/[...slug]'
},
'/products/[slug]': {
en: '/products/[slug]',
de: '/produkte/[slug]',
'de-AT': '/produkte/[slug]',
ja: '/製品/[slug]'
},
'/products/add': {
en: '/products/add',
de: '/produkte/hinzufuegen',
'de-AT': '/produkte/hinzufuegen',
ja: '/製品/追加'
},
'/categories/[[...slug]]': {
en: '/categories/[[...slug]]',
de: '/kategorien/[[...slug]]',
'de-AT': '/kategorien/[[...slug]]',
ja: '/カテゴリー/[[...slug]]'
},
'/categories/new': {
en: '/categories/new',
de: '/kategorien/neu',
'de-AT': '/kategorien/neu',
ja: '/カテゴリー/新着'
}
} satisfies Pathnames<ReadonlyArray<'en' | 'de' | 'de-AT' | 'ja'>>
});
Expand Down Expand Up @@ -642,6 +660,36 @@ describe('prefix-based routing', () => {
);
});

it('priotizes static routes over dynamic and catch-all routes for the non-default locale', () => {
fkapsahili marked this conversation as resolved.
Show resolved Hide resolved
middlewareWithPathnames(createMockRequest('/products/add', 'en'));
middlewareWithPathnames(createMockRequest('/categories/new', 'en'));
expect(MockedNextResponse.next).not.toHaveBeenCalled();
expect(MockedNextResponse.redirect).not.toHaveBeenCalled();
expect(MockedNextResponse.rewrite).toHaveBeenCalledTimes(2);
expect(MockedNextResponse.rewrite.mock.calls[0][0].toString()).toBe(
'http://localhost:3000/en/products/add'
);
expect(MockedNextResponse.rewrite.mock.calls[1][0].toString()).toBe(
'http://localhost:3000/en/categories/new'
);
});

it('priotizes static routes over dynamic and catch-all routes for the non-default locale', () => {
fkapsahili marked this conversation as resolved.
Show resolved Hide resolved
middlewareWithPathnames(
createMockRequest('/de/produkte/hinzufuegen', 'de')
);
middlewareWithPathnames(createMockRequest('/de/kategorien/neu', 'de'));
expect(MockedNextResponse.next).not.toHaveBeenCalled();
expect(MockedNextResponse.redirect).not.toHaveBeenCalled();
expect(MockedNextResponse.rewrite).toHaveBeenCalledTimes(2);
expect(MockedNextResponse.rewrite.mock.calls[0][0].toString()).toBe(
'http://localhost:3000/de/products/add'
);
expect(MockedNextResponse.rewrite.mock.calls[1][0].toString()).toBe(
'http://localhost:3000/de/categories/new'
);
});

it('sets alternate links', () => {
function getLinks(request: NextRequest) {
return middlewareWithPathnames(request)
Expand Down Expand Up @@ -1010,10 +1058,22 @@ describe('prefix-based routing', () => {
en: '/products/[...slug]',
de: '/produkte/[...slug]'
},
'/products/[slug]': {
en: '/products/[slug]',
de: '/produkte/[slug]'
},
'/products/add': {
en: '/products/add',
de: '/produkte/hinzufuegen'
},
'/categories/[[...slug]]': {
en: '/categories/[[...slug]]',
de: '/kategorien/[[...slug]]'
},
'/categories/new': {
en: '/categories/new',
de: '/kategorien/neu'
},
'/internal': '/external',
'/internal/foo/bar': {
en: '/external-en/foo/bar',
Expand Down Expand Up @@ -1100,10 +1160,40 @@ describe('prefix-based routing', () => {
'http://localhost:3000/de/users/1',
'http://localhost:3000/de/news/gutes-neues-jahr-g5b116754',
'http://localhost:3000/de/categories',
'http://localhost:3000/de/categories/neu'
'http://localhost:3000/de/categories/new'
]);
});

it('priotizes static routes over dynamic and catch-all routes for the default locale', () => {
fkapsahili marked this conversation as resolved.
Show resolved Hide resolved
middlewareWithPathnames(createMockRequest('/en/products/add', 'en'));
middlewareWithPathnames(createMockRequest('/en/categories/new', 'en'));
expect(MockedNextResponse.next).not.toHaveBeenCalled();
expect(MockedNextResponse.redirect).not.toHaveBeenCalled();
expect(MockedNextResponse.rewrite).toHaveBeenCalledTimes(2);
expect(MockedNextResponse.rewrite.mock.calls[0][0].toString()).toBe(
'http://localhost:3000/en/products/add'
);
expect(MockedNextResponse.rewrite.mock.calls[1][0].toString()).toBe(
'http://localhost:3000/en/categories/new'
);
});

it('priotizes static routes over dynamic and catch-all routes for the non-default locale', () => {
fkapsahili marked this conversation as resolved.
Show resolved Hide resolved
middlewareWithPathnames(
createMockRequest('/de/produkte/hinzufuegen', 'de')
);
middlewareWithPathnames(createMockRequest('/de/kategorien/neu', 'de'));
expect(MockedNextResponse.next).not.toHaveBeenCalled();
expect(MockedNextResponse.redirect).not.toHaveBeenCalled();
expect(MockedNextResponse.rewrite).toHaveBeenCalledTimes(2);
expect(MockedNextResponse.rewrite.mock.calls[0][0].toString()).toBe(
'http://localhost:3000/de/products/add'
);
expect(MockedNextResponse.rewrite.mock.calls[1][0].toString()).toBe(
'http://localhost:3000/de/categories/new'
);
});

it('redirects a request for a localized route that is not associated with the requested locale', () => {
// Relevant to avoid duplicate content issues
middlewareWithPathnames(createMockRequest('/en/ueber', 'en'));
Expand Down