Skip to content
Closed
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
15 changes: 12 additions & 3 deletions docs/router/framework/react/guide/navigation.md
Original file line number Diff line number Diff line change
Expand Up @@ -201,9 +201,11 @@ As seen above, it's common to provide the `route.fullPath` as the `from` route p

### Special relative paths: `"."` and `".."`

Quite often you might want to reload the current location, for example, to rerun the loaders on the current and/or parent routes, or maybe there was a change in search parameters. This can be achieved by specifying a `to` route path of `"."` which will reload the current location. This is only applicable to the current location, and hence any `from` route path specified is ignored.
Quite often you might want to reload the current location, for example, to rerun the loaders on the current and/or parent routes, or maybe there was a change in search parameters. This can be achieved by specifying a `to` route path of `"."` which will reload the current location.

Another common need is to navigate one route back relative to the current location or some other matched route in the current tree. By specifying a `to` route path of `".."` navigation will be resolved to either the first parent route preceding the current location or, if specified, preceding the `"from"` route path.
Another common need is to navigate one route back relative to the current location. By specifying a `to` route path of `".."` navigation will be resolved to the first parent route preceding the current location.

While both of these can be used in conjunction with `from` to navigate relative to some other matched route in the current tree, it is recommended to use a defined `to` route path should you need to navigate to a different location since this is more explicit in its intent.

```tsx
export const Route = createFileRoute('/posts/$postId')({
Expand All @@ -214,7 +216,14 @@ function PostComponent() {
return (
<div>
<Link to=".">Reload the current route of /posts/$postId</Link>
<Link to="..">Navigate to /posts</Link>
<Link to="..">Navigate back to /posts</Link>
// the below are all equivalent
<Link to="/posts">Navigate back to /posts</Link>
<Link from="/posts" to=".">
Navigate back to /posts
</Link>
// the below are all equivalent
<Link to="/">Navigate to root</Link>
<Link from="/posts" to="..">
Navigate to root
</Link>
Expand Down
1 change: 1 addition & 0 deletions packages/react-router/src/link.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -277,6 +277,7 @@ export function useLinkProps<
// All is well? Navigate!
// N.B. we don't call `router.commitLocation(next) here because we want to run `validateSearch` before committing
router.navigate({
_isDefinedFrom: !!options.from,
...options,
from,
replace,
Expand Down
1 change: 1 addition & 0 deletions packages/react-router/src/useNavigate.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ export function useNavigate<
return navigate({
...options,
from,
_isDefinedFrom: !!options.from,
})
},
// eslint-disable-next-line react-hooks/exhaustive-deps
Expand Down
242 changes: 242 additions & 0 deletions packages/react-router/tests/useNavigate.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1463,6 +1463,248 @@ test.each([true, false])(
},
)

test.each([true, false])(
'should navigate to from route with path params when using "." in nested route structure',
async (trailingSlash) => {
const tail = trailingSlash ? '/' : ''
const rootRoute = createRootRoute()

const IndexComponent = () => {
const navigate = useNavigate()
return (
<>
<h1 data-testid="index-heading">Index</h1>
<button
data-testid="posts-btn"
onClick={() => navigate({ to: '/posts' })}
>
Posts
</button>
</>
)
}

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

const layoutRoute = createRoute({
getParentRoute: () => rootRoute,
id: '_layout',
component: () => {
return (
<>
<h1>Layout</h1>
<Outlet />
</>
)
},
})

const PostsComponent = () => {
const navigate = postsRoute.useNavigate()
return (
<>
<h1 data-testid="posts-index-heading">Posts</h1>
<button
data-testid="first-post-btn"
onClick={() =>
navigate({
to: '$postId',
params: { postId: '1' },
})
}
>
To first post
</button>
<button
data-testid="second-post-btn"
onClick={() =>
navigate({
to: '$postId',
params: { postId: '2' },
})
}
>
To second post
</button>
<button
data-testid="to-posts-index-btn"
onClick={() =>
navigate({
from: '/posts',
to: '.',
})
}
>
To posts list
</button>
<Outlet />
</>
)
}

const PostDetailComponent = () => {
const navigate = postDetailRoute.useNavigate()
return (
<>
<h1 data-testid="post-detail-index-heading">Post Detail</h1>
<button
data-testid="post-info-btn"
onClick={() =>
navigate({
to: 'info',
})
}
>
To post info
</button>
<button
data-testid="post-notes-btn"
onClick={() =>
navigate({
to: 'notes',
})
}
>
To post notes
</button>
<button
data-testid="to-post-detail-index-btn"
onClick={() =>
navigate({
from: '/posts/$postId',
to: '.',
})
}
>
To index detail options
</button>
<Outlet />
</>
)
}

const PostInfoComponent = () => {
return (
<>
<h1 data-testid="post-info-heading">Post Info</h1>
</>
)
}

const PostNotesComponent = () => {
return (
<>
<h1 data-testid="post-notes-heading">Post Notes</h1>
</>
)
}

const postsRoute = createRoute({
getParentRoute: () => layoutRoute,
path: 'posts',
component: PostsComponent,
})

const postDetailRoute = createRoute({
getParentRoute: () => postsRoute,
path: '$postId',
component: PostDetailComponent,
})

const postInfoRoute = createRoute({
getParentRoute: () => postDetailRoute,
path: 'info',
component: PostInfoComponent,
})

const postNotesRoute = createRoute({
getParentRoute: () => postDetailRoute,
path: 'notes',
component: PostNotesComponent,
})

const router = createRouter({
routeTree: rootRoute.addChildren([
indexRoute,
layoutRoute.addChildren([
postsRoute.addChildren([
postDetailRoute.addChildren([postInfoRoute, postNotesRoute]),
]),
]),
]),
trailingSlash: trailingSlash ? 'always' : 'never',
})

render(<RouterProvider router={router} />)

const postsButton = await screen.findByTestId('posts-btn')

fireEvent.click(postsButton)

expect(await screen.findByTestId('posts-index-heading')).toBeInTheDocument()
expect(window.location.pathname).toEqual(`/posts${tail}`)

const firstPostButton = await screen.findByTestId('first-post-btn')

fireEvent.click(firstPostButton)

expect(
await screen.findByTestId('post-detail-index-heading'),
).toBeInTheDocument()
expect(window.location.pathname).toEqual(`/posts/1${tail}`)

const postInfoButton = await screen.findByTestId('post-info-btn')

fireEvent.click(postInfoButton)

expect(await screen.findByTestId('post-info-heading')).toBeInTheDocument()
expect(window.location.pathname).toEqual(`/posts/1/info${tail}`)

const toPostDetailIndexButton = await screen.findByTestId(
'to-post-detail-index-btn',
)

fireEvent.click(toPostDetailIndexButton)

expect(
await screen.findByTestId('post-detail-index-heading'),
).toBeInTheDocument()
expect(screen.queryByTestId("'post-info-heading")).not.toBeInTheDocument()
expect(window.location.pathname).toEqual(`/posts/1${tail}`)

const postNotesButton = await screen.findByTestId('post-notes-btn')

fireEvent.click(postNotesButton)

expect(await screen.findByTestId('post-notes-heading')).toBeInTheDocument()
expect(window.location.pathname).toEqual(`/posts/1/notes${tail}`)

const toPostsIndexButton = await screen.findByTestId('to-posts-index-btn')

fireEvent.click(toPostsIndexButton)

expect(await screen.findByTestId('posts-index-heading')).toBeInTheDocument()
expect(screen.queryByTestId("'post-notes-heading")).not.toBeInTheDocument()
expect(
screen.queryByTestId("'post-detail-index-heading"),
).not.toBeInTheDocument()
expect(window.location.pathname).toEqual(`/posts${tail}`)

const secondPostButton = await screen.findByTestId('second-post-btn')

fireEvent.click(secondPostButton)

expect(
await screen.findByTestId('post-detail-index-heading'),
).toBeInTheDocument()
expect(window.location.pathname).toEqual(`/posts/2${tail}`)
},
)

test.each([true, false])(
'should navigate to current route with changing path params when using "." in nested route structure',
async (trailingSlash) => {
Expand Down
4 changes: 3 additions & 1 deletion packages/router-core/src/RouterProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,9 @@ export type NavigateFn = <
TMaskFrom extends RoutePaths<TRouter['routeTree']> | string = TFrom,
TMaskTo extends string = '',
>(
opts: NavigateOptions<TRouter, TFrom, TTo, TMaskFrom, TMaskTo>,
opts: NavigateOptions<TRouter, TFrom, TTo, TMaskFrom, TMaskTo> & {
_isDefinedFrom?: boolean
},
) => Promise<void>

export type BuildLocationFn = <
Expand Down
11 changes: 9 additions & 2 deletions packages/router-core/src/router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -461,6 +461,7 @@ export interface BuildNextOptions {
_fromLocation?: ParsedLocation
unsafeRelative?: 'path'
_isNavigate?: boolean
_isDefinedFrom?: boolean
}

type NavigationEventInfo = {
Expand Down Expand Up @@ -1422,7 +1423,9 @@ export class RouterCore<
// By default, start with the current location
let fromPath = this.resolvePathWithBase(lastMatch.fullPath, '.')
const toPath = dest.to
? this.resolvePathWithBase(fromPath, `${dest.to}`)
? dest.from && dest._isDefinedFrom
? this.resolvePathWithBase(dest.from, `${dest.to}`)
: this.resolvePathWithBase(fromPath, `${dest.to}`)
: this.resolvePathWithBase(fromPath, '.')

const routeIsChanging =
Expand All @@ -1437,7 +1440,11 @@ export class RouterCore<
fromPath = dest.from

// do this check only on navigations during test or development
if (process.env.NODE_ENV !== 'production' && dest._isNavigate) {
if (
process.env.NODE_ENV !== 'production' &&
dest._isNavigate &&
dest._isDefinedFrom
) {
const allFromMatches = this.getMatchedRoutes(
dest.from,
undefined,
Expand Down
1 change: 1 addition & 0 deletions packages/solid-router/src/link.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -287,6 +287,7 @@ export function useLinkProps<
// All is well? Navigate!
// N.B. we don't call `router.commitLocation(next) here because we want to run `validateSearch` before committing
router.navigate({
_isDefinedFrom: !!_options().from,
..._options(),
replace: local.replace,
resetScroll: local.resetScroll,
Expand Down
1 change: 1 addition & 0 deletions packages/solid-router/src/useNavigate.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ export function Navigate<

Solid.onMount(() => {
navigate({
_isDefinedFrom: !!props.from,
...props,
})
})
Expand Down