Skip to content

Commit e5bff32

Browse files
committed
fix: fix href construction, add tests
1 parent 1cb89aa commit e5bff32

File tree

5 files changed

+360
-4
lines changed

5 files changed

+360
-4
lines changed

packages/next/src/shared/lib/router/utils/construct-href.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,11 +13,11 @@ export function constructHref(
1313
for (const [key, value] of Object.entries(params)) {
1414
if (Array.isArray(value)) {
1515
// Handle catch-all routes like [...slug] and [[...slug]]
16-
const joinedValue = value.join('/')
16+
const joinedValue = value.filter((v) => v !== '').join('/')
17+
// Handle optional catch-all [[...slug]] first (more specific pattern)
18+
href = href.replace(`[[...${key}]]`, joinedValue)
1719
// Handle required catch-all [...slug]
1820
href = href.replace(`[...${key}]`, joinedValue)
19-
// Handle optional catch-all [[...slug]]
20-
href = href.replace(`[[...${key}]]`, joinedValue)
2121
} else {
2222
// Handle regular dynamic routes like [slug]
2323
href = href.replace(`[${key}]`, value)

test/e2e/app-dir/typed-routes/app/(auth)/login/page.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ export default function LoginPage(props: PageProps<'/login'>) {
66
<h2>Login Page</h2>
77
<p>Please log in to continue.</p>
88
<Link path="/blog/[slug]" params={{ slug: 'hello' }}>
9-
Blog Post
9+
Shop
1010
</Link>
1111
</div>
1212
)
Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
import Link from 'next/link'
2+
3+
export default function TypedLinksDemo() {
4+
return (
5+
<div>
6+
<h1>Typed Links Demo</h1>
7+
<p>This page demonstrates the typed links feature.</p>
8+
9+
<h2>Basic Links</h2>
10+
<ul>
11+
<li>
12+
<Link path="/">Home</Link>
13+
</li>
14+
<li>
15+
<Link path="/dashboard">Dashboard</Link>
16+
</li>
17+
<li>
18+
<Link path="/login">Login</Link>
19+
</li>
20+
</ul>
21+
22+
<h2>Dynamic Links</h2>
23+
<ul>
24+
<li>
25+
<Link path="/blog/[slug]" params={{ slug: 'my-first-post' }}>
26+
My First Post
27+
</Link>
28+
</li>
29+
<li>
30+
<Link path="/blog/[slug]" params={{ slug: 'nextjs-tutorial' }}>
31+
Next.js Tutorial
32+
</Link>
33+
</li>
34+
<li>
35+
<Link path="/gallery/photo/[id]" params={{ id: '123' }}>
36+
Photo 123
37+
</Link>
38+
</li>
39+
</ul>
40+
41+
<h2>Catch-all Links</h2>
42+
<ul>
43+
<li>
44+
<Link path="/docs/[...slug]" params={{ slug: ['getting-started'] }}>
45+
Getting Started
46+
</Link>
47+
</li>
48+
<li>
49+
<Link path="/docs/[...slug]" params={{ slug: ['api', 'reference'] }}>
50+
API Reference
51+
</Link>
52+
</li>
53+
<li>
54+
<Link
55+
path="/docs/[...slug]"
56+
params={{ slug: ['guide', 'routing', 'dynamic'] }}
57+
>
58+
Dynamic Routing Guide
59+
</Link>
60+
</li>
61+
</ul>
62+
63+
<h2>Optional Catch-all Links</h2>
64+
<ul>
65+
<li>
66+
<Link path="/shop/[[...category]]" params={{}}>
67+
Shop Home
68+
</Link>
69+
</li>
70+
<li>
71+
<Link
72+
path="/shop/[[...category]]"
73+
params={{ category: ['electronics'] }}
74+
>
75+
Electronics
76+
</Link>
77+
</li>
78+
<li>
79+
<Link
80+
path="/shop/[[...category]]"
81+
params={{ category: ['electronics', 'phones'] }}
82+
>
83+
Phones
84+
</Link>
85+
</li>
86+
</ul>
87+
88+
<h2>Links with Search Parameters</h2>
89+
<ul>
90+
<li>
91+
<Link
92+
path="/blog/[slug]"
93+
params={{ slug: 'search-demo' }}
94+
searchParams={{ utm_source: 'demo' }}
95+
>
96+
Blog with UTM
97+
</Link>
98+
</li>
99+
<li>
100+
<Link
101+
path="/dashboard"
102+
searchParams={{ tab: 'settings', view: 'grid' }}
103+
>
104+
Dashboard Settings
105+
</Link>
106+
</li>
107+
<li>
108+
<Link
109+
path="/docs/[...slug]"
110+
params={{ slug: ['search'] }}
111+
searchParams={{ q: 'routing', filter: 'latest' }}
112+
>
113+
Search Docs
114+
</Link>
115+
</li>
116+
</ul>
117+
118+
<h2>Mixed with Traditional Links</h2>
119+
<ul>
120+
<li>
121+
<Link href="/about">About (href)</Link>
122+
</li>
123+
<li>
124+
<Link href="https://nextjs.org">Next.js Website (external)</Link>
125+
</li>
126+
<li>
127+
<Link path="/blog/[slug]" params={{ slug: 'typed-links' }}>
128+
Typed Links Post (path)
129+
</Link>
130+
</li>
131+
</ul>
132+
</div>
133+
)
134+
}
Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
import { nextTestSetup } from 'e2e-utils'
2+
3+
describe('typed-links', () => {
4+
const { next } = nextTestSetup({
5+
files: __dirname,
6+
})
7+
8+
it('should work with typed links using path and params', async () => {
9+
const $ = await next.render$('/blog/hello-world')
10+
11+
// Check that the page renders correctly
12+
expect($('div').text()).toContain('Blog post: hello-world')
13+
14+
// Check that the typed link exists
15+
const link = $('a[href="/login"]')
16+
expect(link.length).toBe(1)
17+
expect(link.text()).toBe('Hey')
18+
})
19+
20+
it('should work with typed links using params', async () => {
21+
const $ = await next.render$('/login')
22+
23+
// Check that the page renders correctly (there are two h2 elements - one from layout, one from page)
24+
expect($('main h2').text()).toBe('Login Page')
25+
26+
// Check that the typed link with params exists
27+
const link = $('a[href="/blog/hello"]')
28+
expect(link.length).toBe(1)
29+
expect(link.text()).toBe('Shop')
30+
})
31+
32+
it('should construct href correctly from path and params', async () => {
33+
// Use the existing demo page to test href construction
34+
const $ = await next.render$('/typed-links-demo')
35+
36+
// Check that hrefs are constructed correctly
37+
expect($('a[href="/blog/my-first-post"]').length).toBe(1)
38+
expect($('a[href="/docs/getting-started"]').length).toBe(1)
39+
expect($('a[href="/shop/electronics/phones"]').length).toBe(1)
40+
expect($('a[href="/shop"]').length).toBe(1)
41+
})
42+
43+
it('should work with searchParams', async () => {
44+
// Use the existing demo page to test searchParams
45+
const $ = await next.render$('/typed-links-demo')
46+
47+
// Check that hrefs are constructed correctly with search params
48+
expect($('a[href="/blog/search-demo?utm_source=demo"]').length).toBe(1)
49+
expect($('a[href="/dashboard?tab=settings&view=grid"]').length).toBe(1)
50+
})
51+
52+
it('should handle navigation correctly', async () => {
53+
// Test that clicking typed links actually navigates correctly
54+
const browser = await next.browser('/login')
55+
56+
// Click the typed link
57+
await browser.elementByCss('a[href="/blog/hello"]').click()
58+
59+
// Check that we navigated to the correct page
60+
await browser.waitForElementByCss('div:has-text("Blog post: hello")')
61+
62+
// Verify the URL
63+
expect(await browser.url()).toMatch(/\/blog\/hello$/)
64+
})
65+
66+
it('should work with regular href links alongside typed links', async () => {
67+
// Use the existing demo page to test mixed links
68+
const $ = await next.render$('/typed-links-demo')
69+
70+
// Check that both types of links work
71+
expect($('a[href="/about"]').text()).toBe('About (href)')
72+
expect($('a[href="/blog/typed-links"]').text()).toBe(
73+
'Typed Links Post (path)'
74+
)
75+
expect($('a[href="https://nextjs.org"]').text()).toBe(
76+
'Next.js Website (external)'
77+
)
78+
})
79+
80+
it('should generate correct TypeScript types for typed links', async () => {
81+
// Check that the generated route types file contains the necessary module augmentations
82+
const routeTypesContent = await next.readFile('.next/types/routes.d.ts')
83+
84+
// Check for Link module augmentation
85+
expect(routeTypesContent).toContain("declare module 'next/link'")
86+
expect(routeTypesContent).toContain('LinkPropsWithHref')
87+
expect(routeTypesContent).toContain('LinkPropsWithPath')
88+
expect(routeTypesContent).toContain('export type LinkProps')
89+
90+
// Check for proper generic type support
91+
expect(routeTypesContent).toContain('RouteType extends Routes')
92+
})
93+
})

test/unit/construct-href.test.ts

Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
import { constructHref } from '../../packages/next/src/shared/lib/router/utils/construct-href'
2+
3+
describe('constructHref', () => {
4+
it('should handle static paths without params', () => {
5+
expect(constructHref('/')).toBe('/')
6+
expect(constructHref('/about')).toBe('/about')
7+
expect(constructHref('/dashboard/settings')).toBe('/dashboard/settings')
8+
})
9+
10+
it('should handle dynamic segments with params', () => {
11+
expect(constructHref('/blog/[slug]', { slug: 'hello-world' })).toBe(
12+
'/blog/hello-world'
13+
)
14+
expect(constructHref('/users/[id]', { id: '123' })).toBe('/users/123')
15+
expect(
16+
constructHref('/posts/[id]/comments/[commentId]', {
17+
id: '456',
18+
commentId: '789',
19+
})
20+
).toBe('/posts/456/comments/789')
21+
})
22+
23+
it('should handle catch-all routes', () => {
24+
expect(
25+
constructHref('/docs/[...slug]', { slug: ['guide', 'getting-started'] })
26+
).toBe('/docs/guide/getting-started')
27+
expect(
28+
constructHref('/api/[...path]', {
29+
path: ['users', 'profile', 'settings'],
30+
})
31+
).toBe('/api/users/profile/settings')
32+
expect(
33+
constructHref('/files/[...path]', {
34+
path: ['folder', 'subfolder', 'file.txt'],
35+
})
36+
).toBe('/files/folder/subfolder/file.txt')
37+
})
38+
39+
it('should handle optional catch-all routes', () => {
40+
expect(
41+
constructHref('/shop/[[...category]]', {
42+
category: ['electronics', 'phones'],
43+
})
44+
).toBe('/shop/electronics/phones')
45+
expect(
46+
constructHref('/shop/[[...category]]', { category: ['books'] })
47+
).toBe('/shop/books')
48+
expect(constructHref('/shop/[[...category]]', {})).toBe('/shop')
49+
expect(constructHref('/shop/[[...category]]')).toBe('/shop')
50+
})
51+
52+
it('should handle search params', () => {
53+
expect(constructHref('/search', undefined, { q: 'nextjs' })).toBe(
54+
'/search?q=nextjs'
55+
)
56+
expect(
57+
constructHref('/search', undefined, { q: 'nextjs', filter: 'latest' })
58+
).toBe('/search?q=nextjs&filter=latest')
59+
expect(
60+
constructHref(
61+
'/blog/[slug]',
62+
{ slug: 'test' },
63+
{ utm_source: 'newsletter' }
64+
)
65+
).toBe('/blog/test?utm_source=newsletter')
66+
})
67+
68+
it('should handle search params with arrays', () => {
69+
expect(
70+
constructHref('/search', undefined, { tags: ['react', 'nextjs'] })
71+
).toBe('/search?tags=react&tags=nextjs')
72+
expect(
73+
constructHref('/filter', undefined, {
74+
category: ['tech', 'web'],
75+
sort: 'date',
76+
})
77+
).toBe('/filter?category=tech&category=web&sort=date')
78+
})
79+
80+
it('should handle complex combinations', () => {
81+
expect(
82+
constructHref(
83+
'/docs/[...slug]',
84+
{ slug: ['api', 'reference'] },
85+
{ version: '13', tab: 'examples' }
86+
)
87+
).toBe('/docs/api/reference?version=13&tab=examples')
88+
89+
expect(
90+
constructHref(
91+
'/shop/[[...category]]',
92+
{ category: ['electronics'] },
93+
{ sort: 'price', filter: ['available', 'popular'] }
94+
)
95+
).toBe('/shop/electronics?sort=price&filter=available&filter=popular')
96+
})
97+
98+
it('should handle empty params correctly', () => {
99+
expect(constructHref('/blog/[slug]', {})).toBe('/blog/[slug]')
100+
expect(constructHref('/docs/[...slug]', {})).toBe('/docs/[...slug]')
101+
expect(constructHref('/shop/[[...category]]', {})).toBe('/shop')
102+
})
103+
104+
it('should handle undefined params correctly', () => {
105+
expect(constructHref('/blog/[slug]')).toBe('/blog/[slug]')
106+
expect(constructHref('/docs/[...slug]')).toBe('/docs/[...slug]')
107+
expect(constructHref('/shop/[[...category]]')).toBe('/shop')
108+
})
109+
110+
it('should handle special characters in params', () => {
111+
expect(constructHref('/blog/[slug]', { slug: 'hello-world-2023' })).toBe(
112+
'/blog/hello-world-2023'
113+
)
114+
expect(constructHref('/search', undefined, { q: 'react & nextjs' })).toBe(
115+
'/search?q=react+%26+nextjs'
116+
)
117+
expect(constructHref('/user/[id]', { id: '[email protected]' })).toBe(
118+
119+
)
120+
})
121+
122+
it('should handle empty string params', () => {
123+
expect(constructHref('/blog/[slug]', { slug: '' })).toBe('/blog/')
124+
expect(constructHref('/docs/[...slug]', { slug: [''] })).toBe('/docs/')
125+
expect(constructHref('/shop/[[...category]]', { category: [''] })).toBe(
126+
'/shop/'
127+
)
128+
})
129+
})

0 commit comments

Comments
 (0)