Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 24 additions & 3 deletions packages/react-router/src/link.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -547,6 +547,13 @@ export interface LinkComponentRoute<
): React.ReactElement
}

export interface CreateLinkOptions<
TRouter extends AnyRouter,
TFrom extends RoutePaths<TRouter['routeTree']>,
> {
from: TFrom
}

/**
* Creates a typed Link-like component that preserves TanStack Router's
* navigation semantics and type-safety while delegating rendering to the
Expand All @@ -556,14 +563,28 @@ export interface LinkComponentRoute<
* router-aware props (eg. `to`, `params`, `search`, `preload`).
*
* @param Comp The host component to render (eg. a design-system Link/Button)
* @param options Optional config with `from` for type-safe relative navigation
* @returns A router-aware component with the same API as `Link`.
* @example
* const ButtonLink = createLink(MyButton)
* const DashboardLink = createLink(MyButton, { from: '/dashboard' })
* <DashboardLink to="./settings" /> // Type-safe relative to /dashboard
* @link https://tanstack.com/router/latest/docs/framework/react/guide/custom-link
*/
export function createLink<const TComp>(
export function createLink<
TRouter extends AnyRouter = RegisteredRouter,
const TComp = 'a',
const TFrom extends RoutePaths<TRouter['routeTree']> = RoutePaths<
TRouter['routeTree']
>,
>(
Comp: Constrain<TComp, any, (props: CreateLinkProps) => ReactNode>,
): LinkComponent<TComp> {
options?: CreateLinkOptions<TRouter, TFrom>,
): LinkComponent<TComp, TFrom> {
return React.forwardRef(function CreatedLink(props, ref) {
return <Link {...(props as any)} _asChild={Comp} ref={ref} />
return (
<Link {...(props as any)} _asChild={Comp} from={options?.from} ref={ref} />
)
}) as any
}

Expand Down
36 changes: 35 additions & 1 deletion packages/react-router/src/route.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,9 @@ import type { UseLoaderDepsRoute } from './useLoaderDeps'
import type { UseParamsRoute } from './useParams'
import type { UseSearchRoute } from './useSearch'
import type { UseRouteContextRoute } from './useRouteContext'
import type { LinkComponentRoute } from './link'
import type { LinkComponent, LinkComponentRoute, CreateLinkProps } from './link'
import type { Constrain } from '@tanstack/router-core'
import type { ReactNode } from 'react'

declare module '@tanstack/router-core' {
export interface UpdatableRouteOptionsExtensions {
Expand Down Expand Up @@ -75,6 +77,9 @@ declare module '@tanstack/router-core' {
useLoaderData: UseLoaderDataRoute<TId>
useNavigate: () => UseNavigateResult<TFullPath>
Link: LinkComponentRoute<TFullPath>
createLink: <const TComp>(
Comp: Constrain<TComp, any, (props: CreateLinkProps) => ReactNode>,
) => LinkComponent<TComp, TFullPath>
}
}

Expand Down Expand Up @@ -165,6 +170,17 @@ export class RouteApi<
}) as unknown as LinkComponentRoute<
RouteTypesById<TRouter, TId>['fullPath']
>

createLink<const TComp>(
Comp: Constrain<TComp, any, (props: CreateLinkProps) => ReactNode>,
): LinkComponent<TComp, RouteTypesById<TRouter, TId>['fullPath']> {
const id = this.id
return React.forwardRef(function CreatedLink(props: any, ref) {
const router = useRouter()
const from = router.routesById[id as string].fullPath
return <Link {...props} _asChild={Comp} from={from} ref={ref} />
}) as any
}
}

export class Route<
Expand Down Expand Up @@ -314,6 +330,15 @@ export class Route<
return <Link ref={ref} from={this.fullPath as never} {...props} />
},
) as unknown as LinkComponentRoute<TFullPath>

createLink<const TComp>(
Comp: Constrain<TComp, any, (props: CreateLinkProps) => ReactNode>,
): LinkComponent<TComp, TFullPath> {
const from = this.fullPath
return React.forwardRef(function CreatedLink(props: any, ref) {
return <Link {...props} _asChild={Comp} from={from} ref={ref} />
}) as any
}
}

/**
Expand Down Expand Up @@ -590,6 +615,15 @@ export class RootRoute<
return <Link ref={ref} from={this.fullPath} {...props} />
},
) as unknown as LinkComponentRoute<'/'>

createLink<const TComp>(
Comp: Constrain<TComp, any, (props: CreateLinkProps) => ReactNode>,
): LinkComponent<TComp, '/'> {
const from = this.fullPath
return React.forwardRef(function CreatedLink(props: any, ref) {
return <Link {...props} _asChild={Comp} from={from} ref={ref} />
}) as any
}
}

/**
Expand Down
113 changes: 113 additions & 0 deletions packages/react-router/tests/createLink-from.test-d.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
import { test } from 'vitest'
import React from 'react'
import {
createLink,
createRootRoute,
createRoute,
createRouter,
} from '../src'

// Simple route tree:
// /
// /dashboard
// /dashboard/settings
// /dashboard/users
// /dashboard/users/$userId
// /posts

const rootRoute = createRootRoute()

const indexRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/',
})

const dashboardRoute = createRoute({
getParentRoute: () => rootRoute,
path: 'dashboard',
})

const settingsRoute = createRoute({
getParentRoute: () => dashboardRoute,
path: 'settings',
})

const usersRoute = createRoute({
getParentRoute: () => dashboardRoute,
path: 'users',
})

const userRoute = createRoute({
getParentRoute: () => usersRoute,
path: '$userId',
})

const postsRoute = createRoute({
getParentRoute: () => rootRoute,
path: 'posts',
})

const routeTree = rootRoute.addChildren([
indexRoute,
dashboardRoute.addChildren([
settingsRoute,
usersRoute.addChildren([userRoute]),
]),
postsRoute,
])

const router = createRouter({ routeTree })

declare module '../src' {
interface Register {
router: typeof router
}
}

// Custom component for createLink
const Button = (props: { children?: React.ReactNode }) => (
<button {...props} />
)

test('createLink with from allows valid relative paths', () => {
const DashboardLink = createLink(Button, { from: '/dashboard' })

// Valid: ./settings exists under /dashboard
;<DashboardLink to="./settings">Settings</DashboardLink>

// Valid: ./users exists under /dashboard
;<DashboardLink to="./users">Users</DashboardLink>

// Valid: absolute paths always work
;<DashboardLink to="/posts">Posts</DashboardLink>

// Valid: parent navigation
;<DashboardLink to="..">Home</DashboardLink>
})

test('createLink with from rejects invalid relative paths', () => {
const DashboardLink = createLink(Button, { from: '/dashboard' })

// @ts-expect-error - ./posts does NOT exist under /dashboard
;<DashboardLink to="./posts">Invalid</DashboardLink>

// @ts-expect-error - ./invalid is not a valid child of /dashboard
;<DashboardLink to="./invalid">Invalid</DashboardLink>
})

test('createLink with from requires params for parameterized routes', () => {
const UsersLink = createLink(Button, { from: '/dashboard/users' })

// Valid: navigating to $userId with params (descendant path without ./)
;<UsersLink to="$userId" params={{ userId: '123' }}>
User
</UsersLink>

// Valid: also works with explicit ./ relative prefix
;<UsersLink to="./$userId" params={{ userId: '123' }}>
User
</UsersLink>

// @ts-expect-error - missing required userId param
;<UsersLink to="$userId">User</UsersLink>
})