Skip to content

Commit 40eda62

Browse files
committed
feat: initial typed links support
1 parent 639db50 commit 40eda62

File tree

5 files changed

+469
-136
lines changed

5 files changed

+469
-136
lines changed

packages/next/src/client/app-dir/link.tsx

Lines changed: 137 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -21,38 +21,16 @@ import {
2121
import { isLocalURL } from '../../shared/lib/router/utils/is-local-url'
2222
import { dispatchNavigateAction } from '../components/app-router-instance'
2323
import { errorOnce } from '../../shared/lib/utils/error-once'
24+
import { constructHref } from '../../shared/lib/router/utils/construct-href'
2425

2526
type Url = string | UrlObject
26-
type RequiredKeys<T> = {
27-
[K in keyof T]-?: {} extends Pick<T, K> ? never : K
28-
}[keyof T]
2927
type OptionalKeys<T> = {
3028
[K in keyof T]-?: {} extends Pick<T, K> ? K : never
3129
}[keyof T]
3230

3331
type OnNavigateEventHandler = (event: { preventDefault: () => void }) => void
3432

35-
type InternalLinkProps = {
36-
/**
37-
* **Required**. The path or URL to navigate to. It can also be an object (similar to `URL`).
38-
*
39-
* @example
40-
* ```tsx
41-
* // Navigate to /dashboard:
42-
* <Link href="/dashboard">Dashboard</Link>
43-
*
44-
* // Navigate to /about?name=test:
45-
* <Link href={{ pathname: '/about', query: { name: 'test' } }}>
46-
* About
47-
* </Link>
48-
* ```
49-
*
50-
* @remarks
51-
* - For external URLs, use a fully qualified URL such as `https://...`.
52-
* - In the App Router, dynamic routes must not include bracketed segments in `href`.
53-
*/
54-
href: Url
55-
33+
type InternalLinkPropsBase = {
5634
/**
5735
* @deprecated v10.0.0: `href` props pointing to a dynamic route are
5836
* automatically resolved and no longer require the `as` prop.
@@ -212,14 +190,70 @@ type InternalLinkProps = {
212190
onNavigate?: OnNavigateEventHandler
213191
}
214192

193+
type InternalLinkProps = InternalLinkPropsBase &
194+
(
195+
| {
196+
/**
197+
* **Required**. The path or URL to navigate to. It can also be an object (similar to `URL`).
198+
* Accepts any string for external URLs and backwards compatibility.
199+
*
200+
* @example
201+
* ```tsx
202+
* // Navigate to /dashboard:
203+
* <Link href="/dashboard">Dashboard</Link>
204+
*
205+
* // External URL:
206+
* <Link href="https://example.com">External Site</Link>
207+
*
208+
* // Navigate to /about?name=test:
209+
* <Link href={{ pathname: '/about', query: { name: 'test' } }}>
210+
* About
211+
* </Link>
212+
* ```
213+
*
214+
* @remarks
215+
* - For external URLs, use a fully qualified URL such as `https://...`.
216+
* - In the App Router, dynamic routes must not include bracketed segments in `href`.
217+
*/
218+
href: Url
219+
220+
/**
221+
* These props are not available when using href
222+
*/
223+
path?: never
224+
params?: never
225+
searchParams?: never
226+
}
227+
| {
228+
/**
229+
* The href property is not available when using path
230+
*/
231+
href?: never
232+
233+
/**
234+
* The route path template for typed links (e.g., '/blog/[slug]')
235+
*/
236+
path: string
237+
238+
/**
239+
* Parameters for dynamic route segments (only available with path)
240+
*/
241+
params?: Record<string, string | string[]>
242+
243+
/**
244+
* Search parameters to append to the URL (only available with path)
245+
*/
246+
searchParams?: Record<string, string | string[]>
247+
}
248+
)
249+
215250
// TODO-APP: Include the full set of Anchor props
216251
// adding this to the publicly exported type currently breaks existing apps
217252

218253
// `RouteInferType` is a stub here to avoid breaking `typedRoutes` when the type
219254
// isn't generated yet. It will be replaced when the webpack plugin runs.
220255
// eslint-disable-next-line @typescript-eslint/no-unused-vars
221256
export type LinkProps<RouteInferType = any> = InternalLinkProps
222-
type LinkPropsRequired = RequiredKeys<LinkProps>
223257
type LinkPropsOptional = OptionalKeys<Omit<InternalLinkProps, 'locale'>>
224258

225259
function isModifiedEvent(event: React.MouseEvent): boolean {
@@ -341,8 +375,11 @@ export default function LinkComponent(
341375
onNavigate,
342376
ref: forwardedRef,
343377
unstable_dynamicOnHover,
378+
path,
379+
params,
380+
searchParams,
344381
...restProps
345-
} = props
382+
} = props as any // TypeScript discriminated union handled at type level
346383

347384
children = childrenProp
348385

@@ -382,31 +419,41 @@ export default function LinkComponent(
382419
)
383420
}
384421

385-
// TypeScript trick for type-guarding:
386-
const requiredPropsGuard: Record<LinkPropsRequired, true> = {
387-
href: true,
388-
} as const
389-
const requiredProps: LinkPropsRequired[] = Object.keys(
390-
requiredPropsGuard
391-
) as LinkPropsRequired[]
392-
requiredProps.forEach((key: LinkPropsRequired) => {
393-
if (key === 'href') {
394-
if (
395-
props[key] == null ||
396-
(typeof props[key] !== 'string' && typeof props[key] !== 'object')
397-
) {
398-
throw createPropError({
399-
key,
400-
expected: '`string` or `object`',
401-
actual: props[key] === null ? 'null' : typeof props[key],
402-
})
403-
}
404-
} else {
405-
// TypeScript trick for type-guarding:
406-
// eslint-disable-next-line @typescript-eslint/no-unused-vars
407-
const _: never = key
422+
// Validate that exactly one of href or path is provided
423+
if (hrefProp && path) {
424+
throw new Error(
425+
'Invalid <Link> with both `href` and `path` props. You must use exactly one of these props.'
426+
)
427+
}
428+
429+
if (!hrefProp && !path) {
430+
throw new Error(
431+
'Invalid <Link> with neither `href` nor `path` prop. You must provide exactly one of these props.'
432+
)
433+
}
434+
435+
if (params && !path) {
436+
throw new Error(
437+
'Invalid <Link> with `params` prop but no `path` prop. `params` can only be used with `path`.'
438+
)
439+
}
440+
441+
if (searchParams && !path) {
442+
throw new Error(
443+
'Invalid <Link> with `searchParams` prop but no `path` prop. `searchParams` can only be used with `path`.'
444+
)
445+
}
446+
447+
if (path && !params && !searchParams) {
448+
// Check if path appears to have dynamic segments
449+
if (path.includes('[') && path.includes(']')) {
450+
console.warn(
451+
`<Link> with path="${path}" appears to have dynamic segments but no params were provided. This may result in incorrect URLs.`
452+
)
408453
}
409-
})
454+
}
455+
456+
// Skip the required props validation since we handle it manually above
410457

411458
// TypeScript trick for type-guarding:
412459
const optionalPropsGuard: Record<LinkPropsOptional, true> = {
@@ -422,6 +469,10 @@ export default function LinkComponent(
422469
onTouchStart: true,
423470
legacyBehavior: true,
424471
onNavigate: true,
472+
href: true,
473+
path: true,
474+
params: true,
475+
searchParams: true,
425476
} as const
426477
const optionalProps: LinkPropsOptional[] = Object.keys(
427478
optionalPropsGuard
@@ -437,6 +488,38 @@ export default function LinkComponent(
437488
actual: valType,
438489
})
439490
}
491+
} else if (key === 'href') {
492+
if (props[key] && valType !== 'string' && valType !== 'object') {
493+
throw createPropError({
494+
key,
495+
expected: '`string` or `object`',
496+
actual: valType,
497+
})
498+
}
499+
} else if (key === 'path') {
500+
if (props[key] && valType !== 'string') {
501+
throw createPropError({
502+
key,
503+
expected: '`string`',
504+
actual: valType,
505+
})
506+
}
507+
} else if (key === 'params') {
508+
if (props[key] && valType !== 'object') {
509+
throw createPropError({
510+
key,
511+
expected: '`object`',
512+
actual: valType,
513+
})
514+
}
515+
} else if (key === 'searchParams') {
516+
if (props[key] && valType !== 'object') {
517+
throw createPropError({
518+
key,
519+
expected: '`object`',
520+
actual: valType,
521+
})
522+
}
440523
} else if (
441524
key === 'onClick' ||
442525
key === 'onMouseEnter' ||
@@ -477,10 +560,6 @@ export default function LinkComponent(
477560
actual: valType,
478561
})
479562
}
480-
} else {
481-
// TypeScript trick for type-guarding:
482-
// eslint-disable-next-line @typescript-eslint/no-unused-vars
483-
const _: never = key
484563
}
485564
})
486565
}
@@ -517,12 +596,14 @@ export default function LinkComponent(
517596
}
518597

519598
const { href, as } = React.useMemo(() => {
520-
const resolvedHref = formatStringOrUrl(hrefProp)
599+
const resolvedHref = path
600+
? constructHref(path, params, searchParams)
601+
: formatStringOrUrl(hrefProp!)
521602
return {
522603
href: resolvedHref,
523604
as: asProp ? formatStringOrUrl(asProp) : resolvedHref,
524605
}
525-
}, [hrefProp, asProp])
606+
}, [hrefProp, asProp, path, params, searchParams])
526607

527608
// This will return the first child, if multiple are provided it will throw an error
528609
let child: any

0 commit comments

Comments
 (0)