Skip to content

Commit

Permalink
fix: Handle optional catch-all segments in navigation APIs if no valu…
Browse files Browse the repository at this point in the history
…e is provided and handle the case if a dynamic value appears multiple times in a pathname (#1259)

Fixes #1236
  • Loading branch information
amannn authored Aug 14, 2024
1 parent 2a76acf commit 58ef482
Show file tree
Hide file tree
Showing 3 changed files with 106 additions and 11 deletions.
4 changes: 2 additions & 2 deletions packages/next-intl/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -128,11 +128,11 @@
},
{
"path": "dist/production/navigation.react-client.js",
"limit": "3.475 KB"
"limit": "3.55 KB"
},
{
"path": "dist/production/navigation.react-server.js",
"limit": "18.325 KB"
"limit": "18.355 KB"
},
{
"path": "dist/production/server.react-client.js",
Expand Down
90 changes: 87 additions & 3 deletions packages/next-intl/src/navigation/shared/utils.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,14 +24,98 @@ describe('serializeSearchParams', () => {
});

describe('compileLocalizedPathname', () => {
const locales: ReadonlyArray<string> = ['en', 'de'];
const pathnames = {
'/about/[param]': {
en: '/about/[param]',
de: '/ueber-uns/[param]'
},
'/test/[one]/[two]': '/test/[one]/[two]',
'/test/[one]/[one]': '/test/[one]/[one]',
'/test/[...params]': '/test/[...params]',
'/test/[[...params]]': '/test/[[...params]]'
} as const;

it('compiles a pathname that differs by locale', () => {
expect(
compileLocalizedPathname<typeof locales, '/about/[param]'>({
locale: 'en',
pathname: '/about/[param]',
params: {param: 'value'},
pathnames
})
).toBe('/about/value');
expect(
compileLocalizedPathname<typeof locales, '/about/[param]'>({
locale: 'de',
pathname: '/about/[param]',
params: {param: 'wert'},
pathnames
})
).toBe('/ueber-uns/wert');
});

it('compiles a pathname that is equal for all locales', () => {
expect(
compileLocalizedPathname<typeof locales, '/test/[one]/[two]'>({
locale: 'en',
pathname: '/test/[one]/[two]',
params: {one: '1', two: '2'},
pathnames
})
).toBe('/test/1/2');
});

it('compiles a pathname where a param appears twice', () => {
expect(
compileLocalizedPathname<typeof locales, '/test/[one]/[one]'>({
locale: 'en',
pathname: '/test/[one]/[one]',
params: {one: '1'},
pathnames
})
).toBe('/test/1/1');
});

it('compiles a pathname with a catch-all segment', () => {
expect(
compileLocalizedPathname<typeof locales, '/test/[...params]'>({
locale: 'en',
pathname: '/test/[...params]',
params: {params: ['a', 'b']},
pathnames
})
).toBe('/test/a/b');
});

it('compiles a pathname with an optional catch-all segment if the segment is provided', () => {
expect(
compileLocalizedPathname<typeof locales, '/test/[[...params]]'>({
locale: 'en',
pathname: '/test/[[...params]]',
params: {params: ['a', 'b']},
pathnames
})
).toBe('/test/a/b');
});

it('compiles a pathname with an optional catch-all segment if the segment is absent', () => {
expect(
compileLocalizedPathname<typeof locales, '/test/[[...params]]'>({
locale: 'en',
pathname: '/test/[[...params]]',
pathnames
})
).toBe('/test');
});

it('throws when params were not resolved', () => {
const locales: ReadonlyArray<string> = ['en'];
expect(() =>
// @ts-expect-error -- Purposefully miss a param
compileLocalizedPathname<typeof locales, '/test/[one]/[two]'>({
locale: 'en',
pathname: '/test/[one]/[two]',
pathnames: '/test/[one]/[two]',
pathnames,
// @ts-expect-error -- Purposefully miss a param
params: {one: '1'}
})
).toThrow(
Expand Down
23 changes: 17 additions & 6 deletions packages/next-intl/src/navigation/shared/utils.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
import type {ParsedUrlQueryInput} from 'node:querystring';
import type {UrlObject} from 'url';
import {Locales, Pathnames} from '../../routing/types';
import {matchesPathname, getSortedPathnames} from '../../shared/utils';
import {
matchesPathname,
getSortedPathnames,
normalizeTrailingSlash
} from '../../shared/utils';
import StrictParams from './StrictParams';

type SearchParamValue = ParsedUrlQueryInput[keyof ParsedUrlQueryInput];
Expand Down Expand Up @@ -112,17 +116,24 @@ export function compileLocalizedPathname<AppLocales extends Locales, Pathname>({

if (params) {
Object.entries(params).forEach(([key, value]) => {
let regexp: string, replacer: string;

if (Array.isArray(value)) {
compiled = compiled.replace(
new RegExp(`(\\[)?\\[...${key}\\](\\])?`, 'g'),
value.map((v) => String(v)).join('/')
);
regexp = `(\\[)?\\[...${key}\\](\\])?`;
replacer = value.map((v) => String(v)).join('/');
} else {
compiled = compiled.replace(`[${key}]`, String(value));
regexp = `\\[${key}\\]`;
replacer = String(value);
}

compiled = compiled.replace(new RegExp(regexp, 'g'), replacer);
});
}

// Clean up optional catch-all segments that were not replaced
compiled = compiled.replace(/\[\[\.\.\..+\]\]/g, '');
compiled = normalizeTrailingSlash(compiled);

if (process.env.NODE_ENV !== 'production' && compiled.includes('[')) {
// Next.js throws anyway, therefore better provide a more helpful error message
throw new Error(
Expand Down

0 comments on commit 58ef482

Please sign in to comment.