Skip to content

Commit 09d4588

Browse files
committed
feat: fully working typed links
1 parent c1cce83 commit 09d4588

File tree

7 files changed

+208
-247
lines changed

7 files changed

+208
-247
lines changed

packages/next/errors.json

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -720,5 +720,10 @@
720720
"719": "Failed to get source map for '%s'. This is a bug in Next.js",
721721
"720": "A client prerender store should not be used for a route handler.",
722722
"721": "Render in Browser",
723-
"722": "Unable to match pathname to a dynamic route"
724-
}
723+
"722": "Unable to match pathname to a dynamic route",
724+
"723": "Dynamic href \\`%s\\` found in <Link> while using the \\`/app\\` router, this is not supported. Instead, you should use \\`path\\` and \\`params\\` props. Read more: https://nextjs.org/docs/messages/app-dir-dynamic-href",
725+
"724": "Invalid <Link> with neither `href` nor `path` prop. You must provide exactly one of these props.",
726+
"725": "Invalid <Link> with `params` prop but no `path` prop. `params` can only be used with `path`.",
727+
"726": "Invalid <Link> with both `href` and `path` props. You must use exactly one of these props.",
728+
"727": "Invalid <Link> with `searchParams` prop but no `path` prop. `searchParams` can only be used with `path`."
729+
}

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

Lines changed: 57 additions & 64 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ type OptionalKeys<T> = {
3030

3131
type OnNavigateEventHandler = (event: { preventDefault: () => void }) => void
3232

33-
type InternalLinkPropsBase = {
33+
export type InternalLinkProps = {
3434
/**
3535
* @deprecated v10.0.0: `href` props pointing to a dynamic route are
3636
* automatically resolved and no longer require the `as` prop.
@@ -190,71 +190,64 @@ type InternalLinkPropsBase = {
190190
onNavigate?: OnNavigateEventHandler
191191
}
192192

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

250247
// TODO-APP: Include the full set of Anchor props
251248
// adding this to the publicly exported type currently breaks existing apps
252249

253-
// `RouteInferType` is a stub here to avoid breaking `typedRoutes` when the type
254-
// isn't generated yet. It will be replaced when the webpack plugin runs.
255-
// eslint-disable-next-line @typescript-eslint/no-unused-vars
256-
export type LinkProps<RouteInferType = any> = InternalLinkProps
257-
type LinkPropsOptional = OptionalKeys<Omit<InternalLinkProps, 'locale'>>
250+
type LinkPropsOptional = OptionalKeys<Omit<LinkProps, 'locale'>>
258251

259252
function isModifiedEvent(event: React.MouseEvent): boolean {
260253
const eventTarget = event.currentTarget as HTMLAnchorElement | SVGAElement
@@ -379,7 +372,7 @@ export default function LinkComponent(
379372
params,
380373
searchParams,
381374
...restProps
382-
} = props as any // TypeScript discriminated union handled at type level
375+
} = props
383376

384377
children = childrenProp
385378

@@ -588,7 +581,7 @@ export default function LinkComponent(
588581

589582
if (hasDynamicSegment) {
590583
throw new Error(
591-
`Dynamic href \`${href}\` found in <Link> while using the \`/app\` router, this is not supported. Read more: https://nextjs.org/docs/messages/app-dir-dynamic-href`
584+
`Dynamic href \`${href}\` found in <Link> while using the \`/app\` router, this is not supported. Instead, you should use \`path\` and \`params\` props. Read more: https://nextjs.org/docs/messages/app-dir-dynamic-href`
592585
)
593586
}
594587
}

packages/next/src/client/link.tsx

Lines changed: 54 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ import { constructHref } from '../shared/lib/router/utils/construct-href'
2424
type Url = string | UrlObject
2525
type OnNavigateEventHandler = (event: { preventDefault: () => void }) => void
2626

27-
type InternalLinkPropsBase = {
27+
export type InternalLinkProps = {
2828
/**
2929
* Optional decorator for the path that will be shown in the browser URL bar. Before Next.js 9.5.3 this was used for dynamic routes, check our [previous docs](https://github.com/vercel/next.js/blob/v9.5.2/docs/api-reference/next/link.md#dynamic-routes) to see how it worked. Note: when this path differs from the one provided in `href` the previous `href`/`as` behavior is used as shown in the [previous docs](https://github.com/vercel/next.js/blob/v9.5.2/docs/api-reference/next/link.md#dynamic-routes).
3030
*/
@@ -102,54 +102,63 @@ type InternalLinkPropsBase = {
102102
onNavigate?: OnNavigateEventHandler
103103
}
104104

105-
type InternalLinkProps = InternalLinkPropsBase &
106-
(
107-
| {
108-
/**
109-
* The path or URL to navigate to. It can also be an object.
110-
* Accepts any string for external URLs and backwards compatibility.
111-
*
112-
* @example https://nextjs.org/docs/api-reference/next/link#with-url-object
113-
*/
114-
href: Url
115-
/**
116-
* These props are not available when using href
117-
*/
118-
path?: never
119-
params?: never
120-
searchParams?: never
121-
}
122-
| {
123-
/**
124-
* The href property is not available when using path
125-
*/
126-
href?: never
127-
/**
128-
* The route path template for typed links (e.g., '/blog/[slug]')
129-
*/
130-
path: string
131-
/**
132-
* Parameters for dynamic route segments (only available with path)
133-
*/
134-
params?: Record<string, string | string[]>
135-
/**
136-
* Search parameters to append to the URL (only available with path)
137-
*/
138-
searchParams?: Record<string, string | string[]>
139-
}
140-
)
105+
export type HrefProps = {
106+
/**
107+
* **Required**. The path or URL to navigate to. It can also be an object (similar to `URL`).
108+
* Accepts any string for external URLs and backwards compatibility.
109+
*
110+
* @example
111+
* ```tsx
112+
* // Navigate to /dashboard:
113+
* <Link href="/dashboard">Dashboard</Link>
114+
*
115+
* // External URL:
116+
* <Link href="https://example.com">External Site</Link>
117+
*
118+
* // Navigate to /about?name=test:
119+
* <Link href={{ pathname: '/about', query: { name: 'test' } }}>
120+
* About
121+
* </Link>
122+
* ```
123+
*
124+
* @remarks
125+
* - For external URLs, use a fully qualified URL such as `https://...`.
126+
* - In the App Router, dynamic routes must not include bracketed segments in `href`.
127+
*/
128+
href: Url
129+
130+
/**
131+
* These props are not available when using href
132+
*/
133+
path?: never
134+
params?: never
135+
searchParams?: never
136+
}
137+
138+
type PathProps = {
139+
/**
140+
* The href property is not available when using path
141+
*/
142+
href?: never
143+
/**
144+
* The route path template for typed links (e.g., '/blog/[slug]')
145+
*/
146+
path: string
147+
/**
148+
* Parameters for dynamic route segments (only available with path)
149+
*/
150+
params?: Record<string, string | string[]>
151+
/**
152+
* Search parameters to append to the URL (only available with path)
153+
*/
154+
searchParams?: Record<string, string | string[]>
155+
}
156+
157+
export type LinkProps = InternalLinkProps & (HrefProps | PathProps)
141158

142159
// TODO-APP: Include the full set of Anchor props
143160
// adding this to the publicly exported type currently breaks existing apps
144161

145-
// `RouteInferType` is a stub here to avoid breaking `typedRoutes` when the type
146-
// isn't generated yet. It will be replaced when the webpack plugin runs.
147-
// WARNING: This should be an interface to prevent TypeScript from inlining it
148-
// in declarations of libraries dependending on Next.js.
149-
// Not trivial to reproduce so only convert to an interface when needed.
150-
// eslint-disable-next-line @typescript-eslint/no-unused-vars
151-
export type LinkProps<RouteInferType = any> = InternalLinkProps
152-
153162
const prefetched = new Set<string>()
154163

155164
type PrefetchOptions = RouterPrefetchOptions & {

0 commit comments

Comments
 (0)