Skip to content

Commit 2df8e52

Browse files
fix(Relative routing): Default relative routing to current location (#4978)
When using relative routing we have 3 things to consider: 1) Is this relative to the current location? This is specifically relevant for using `to="."` and `to=".."` in layout routes 2) Is this relative to the rendered route location? 3) Should this be relative to some other route in the tree We have had a few issues where it hasn't been clear how relative routing is applied in various scenarios, and where the application differs, for example `to="."` and `to=".."` was treated differently. This PR applies the following: 1) when no from path is specified, navigation is done from the current location, this does not affect absolute paths but makes relative paths more standardised. 2) other relative navigations require a from location to be specified. This is achieved by setting Link and useNavigate to default to the current active location instead of the rendered route location. This also simplifies the logic in buildLocation as we no longer specifically cater for `to="."`. Included in this PR is the required changes to router-core (buildLocation), react-router and solid-router (Link and useNavigate), tests for both react-router and solid-router and an update to the docs to clarify the usage. This closes #4842 <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Added active-location hooks, origin-aware navigation helpers, programmatic Route API for Link generation, browser-history utilities, optional from origin for Navigate, and route fullPath exposure. * **Bug Fixes** * More deterministic relative navigation (., ..) resolution, preservation of search/params, and runtime warnings for invalid origins. * **Documentation** * Clarified relative-link semantics and updated examples. * **Tests** * Vastly expanded end-to-end coverage for relative navigation, masking, basepath, and trailing-slash permutations. <!-- end of auto-generated comment: release notes by coderabbit.ai --> --------- Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
1 parent 8134650 commit 2df8e52

File tree

12 files changed

+2809
-145
lines changed

12 files changed

+2809
-145
lines changed

docs/router/framework/react/guide/navigation.md

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ type ToOptions<
2828
TTo extends string = '',
2929
> = {
3030
// `from` is an optional route ID or path. If it is not supplied, only absolute paths will be auto-completed and type-safe. It's common to supply the route.fullPath of the origin route you are rendering from for convenience. If you don't know the origin route, leave this empty and work with absolute paths or unsafe relative paths.
31-
from: string
31+
from?: string
3232
// `to` can be an absolute route path or a relative path from the `from` option to a valid route path. ⚠️ Do not interpolate path params, hash or search params into the `to` options. Use the `params`, `search`, and `hash` options instead.
3333
to: string
3434
// `params` is either an object of path params to interpolate into the `to` option or a function that supplies the previous params and allows you to return new ones. This is the only way to interpolate dynamic parameters into the final URL. Depending on the `from` and `to` route, you may need to supply none, some or all of the path params. TypeScript will notify you of the required params if there are any.
@@ -183,7 +183,7 @@ Keep in mind that normally dynamic segment params are `string` values, but they
183183

184184
By default, all links are absolute unless a `from` route path is provided. This means that the above link will always navigate to the `/about` route regardless of what route you are currently on.
185185

186-
If you want to make a link that is relative to the current route, you can provide a `from` route path:
186+
Relative links can be combined with a `from` route path. If a from route path isn't provided, relative paths default to the current active location.
187187

188188
```tsx
189189
const postIdRoute = createRoute({
@@ -201,9 +201,9 @@ As seen above, it's common to provide the `route.fullPath` as the `from` route p
201201

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

204-
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.
204+
Quite often you might want to reload the current location or another `from` path, for example, to rerun the loaders on the current and/or parent routes, or maybe navigate back to a parent route. This can be achieved by specifying a `to` route path of `"."` which will reload the current location or provided `from` path.
205205

206-
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.
206+
Another common need is to navigate one route back relative to the current location or another path. By specifying a `to` route path of `".."` navigation will be resolved to the first parent route preceding the current location.
207207

208208
```tsx
209209
export const Route = createFileRoute('/posts/$postId')({
@@ -214,7 +214,14 @@ function PostComponent() {
214214
return (
215215
<div>
216216
<Link to=".">Reload the current route of /posts/$postId</Link>
217-
<Link to="..">Navigate to /posts</Link>
217+
<Link to="..">Navigate back to /posts</Link>
218+
// the below are all equivalent
219+
<Link to="/posts">Navigate back to /posts</Link>
220+
<Link from="/posts" to=".">
221+
Navigate back to /posts
222+
</Link>
223+
// the below are all equivalent
224+
<Link to="/">Navigate to root</Link>
218225
<Link from="/posts" to="..">
219226
Navigate to root
220227
</Link>

packages/react-router/src/link.tsx

Lines changed: 46 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -7,12 +7,12 @@ import {
77
preloadWarning,
88
removeTrailingSlash,
99
} from '@tanstack/router-core'
10+
import { useActiveLocation } from './useActiveLocation'
1011
import { useRouterState } from './useRouterState'
1112
import { useRouter } from './useRouter'
1213

1314
import { useForwardedRef, useIntersectionObserver } from './utils'
1415

15-
import { useMatch } from './useMatch'
1616
import type {
1717
AnyRouter,
1818
Constrain,
@@ -99,19 +99,27 @@ export function useLinkProps<
9999
structuralSharing: true as any,
100100
})
101101

102-
const from = useMatch({
103-
strict: false,
104-
select: (match) => options.from ?? match.fullPath,
102+
// subscribe to location here to re-build fromPath if it changes
103+
const routerLocation = useRouterState({
104+
select: (s) => s.location,
105+
structuralSharing: true as any,
105106
})
106107

107-
const next = React.useMemo(
108-
() => router.buildLocation({ ...options, from } as any),
108+
const { getFromPath } = useActiveLocation()
109+
110+
const from = getFromPath(options.from)
111+
112+
const _options = React.useMemo(
113+
() => {
114+
return { ...options, from }
115+
},
109116
// eslint-disable-next-line react-hooks/exhaustive-deps
110117
[
111118
router,
119+
routerLocation,
112120
currentSearch,
113-
options._fromLocation,
114121
from,
122+
options._fromLocation,
115123
options.hash,
116124
options.to,
117125
options.search,
@@ -122,6 +130,11 @@ export function useLinkProps<
122130
],
123131
)
124132

133+
const next = React.useMemo(
134+
() => router.buildLocation({ ..._options } as any),
135+
[router, _options],
136+
)
137+
125138
const isExternal = type === 'external'
126139

127140
const preload =
@@ -180,34 +193,12 @@ export function useLinkProps<
180193
},
181194
})
182195

183-
const doPreload = React.useCallback(
184-
() => {
185-
router.preloadRoute({ ...options, from } as any).catch((err) => {
186-
console.warn(err)
187-
console.warn(preloadWarning)
188-
})
189-
},
190-
// eslint-disable-next-line react-hooks/exhaustive-deps
191-
[
192-
router,
193-
options.to,
194-
options._fromLocation,
195-
from,
196-
options.search,
197-
options.hash,
198-
options.params,
199-
options.state,
200-
options.mask,
201-
options.unsafeRelative,
202-
options.hashScrollIntoView,
203-
options.href,
204-
options.ignoreBlocker,
205-
options.reloadDocument,
206-
options.replace,
207-
options.resetScroll,
208-
options.viewTransition,
209-
],
210-
)
196+
const doPreload = React.useCallback(() => {
197+
router.preloadRoute({ ..._options } as any).catch((err) => {
198+
console.warn(err)
199+
console.warn(preloadWarning)
200+
})
201+
}, [router, _options])
211202

212203
const preloadViewportIoCallback = React.useCallback(
213204
(entry: IntersectionObserverEntry | undefined) => {
@@ -235,25 +226,6 @@ export function useLinkProps<
235226
}
236227
}, [disabled, doPreload, preload])
237228

238-
if (isExternal) {
239-
return {
240-
...propsSafeToSpread,
241-
ref: innerRef as React.ComponentPropsWithRef<'a'>['ref'],
242-
type,
243-
href: to,
244-
...(children && { children }),
245-
...(target && { target }),
246-
...(disabled && { disabled }),
247-
...(style && { style }),
248-
...(className && { className }),
249-
...(onClick && { onClick }),
250-
...(onFocus && { onFocus }),
251-
...(onMouseEnter && { onMouseEnter }),
252-
...(onMouseLeave && { onMouseLeave }),
253-
...(onTouchStart && { onTouchStart }),
254-
}
255-
}
256-
257229
// The click handler
258230
const handleClick = (e: React.MouseEvent) => {
259231
if (
@@ -277,8 +249,7 @@ export function useLinkProps<
277249
// All is well? Navigate!
278250
// N.B. we don't call `router.commitLocation(next) here because we want to run `validateSearch` before committing
279251
router.navigate({
280-
...options,
281-
from,
252+
..._options,
282253
replace,
283254
resetScroll,
284255
hashScrollIntoView,
@@ -289,6 +260,25 @@ export function useLinkProps<
289260
}
290261
}
291262

263+
if (isExternal) {
264+
return {
265+
...propsSafeToSpread,
266+
ref: innerRef as React.ComponentPropsWithRef<'a'>['ref'],
267+
type,
268+
href: to,
269+
...(children && { children }),
270+
...(target && { target }),
271+
...(disabled && { disabled }),
272+
...(style && { style }),
273+
...(className && { className }),
274+
...(onClick && { onClick }),
275+
...(onFocus && { onFocus }),
276+
...(onMouseEnter && { onMouseEnter }),
277+
...(onMouseLeave && { onMouseLeave }),
278+
...(onTouchStart && { onTouchStart }),
279+
}
280+
}
281+
292282
// The click handler
293283
const handleFocus = (_: React.MouseEvent) => {
294284
if (disabled) return
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
import { last } from '@tanstack/router-core'
2+
import { useCallback, useEffect, useState } from 'react'
3+
import { useRouter } from './useRouter'
4+
import { useMatch } from './useMatch'
5+
import { useRouterState } from './useRouterState'
6+
import type { ParsedLocation } from '@tanstack/router-core'
7+
8+
export type UseActiveLocationResult = {
9+
activeLocation: ParsedLocation
10+
getFromPath: (from?: string) => string
11+
setActiveLocation: (location?: ParsedLocation) => void
12+
}
13+
14+
export const useActiveLocation = (
15+
location?: ParsedLocation,
16+
): UseActiveLocationResult => {
17+
const router = useRouter()
18+
const routerLocation = useRouterState({ select: (state) => state.location })
19+
const [activeLocation, setActiveLocation] = useState<ParsedLocation>(
20+
location ?? routerLocation,
21+
)
22+
const [customActiveLocation, setCustomActiveLocation] = useState<
23+
ParsedLocation | undefined
24+
>(location)
25+
26+
useEffect(() => {
27+
setActiveLocation(customActiveLocation ?? routerLocation)
28+
}, [routerLocation, customActiveLocation])
29+
30+
const matchIndex = useMatch({
31+
strict: false,
32+
select: (match) => match.index,
33+
})
34+
35+
const getFromPath = useCallback(
36+
(from?: string) => {
37+
const activeLocationMatches = router.matchRoutes(activeLocation, {
38+
_buildLocation: false,
39+
})
40+
41+
const activeLocationMatch = last(activeLocationMatches)
42+
43+
return (
44+
from ??
45+
activeLocationMatch?.fullPath ??
46+
router.state.matches[matchIndex]!.fullPath
47+
)
48+
},
49+
[activeLocation, matchIndex, router],
50+
)
51+
52+
return {
53+
activeLocation,
54+
getFromPath,
55+
setActiveLocation: setCustomActiveLocation,
56+
}
57+
}

packages/react-router/src/useNavigate.tsx

Lines changed: 6 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import * as React from 'react'
22
import { useRouter } from './useRouter'
3-
import { useMatch } from './useMatch'
3+
import { useActiveLocation } from './useActiveLocation'
44
import type {
55
AnyRouter,
66
FromPathOption,
@@ -15,29 +15,21 @@ export function useNavigate<
1515
>(_defaultOpts?: {
1616
from?: FromPathOption<TRouter, TDefaultFrom>
1717
}): UseNavigateResult<TDefaultFrom> {
18-
const { navigate, state } = useRouter()
18+
const router = useRouter()
1919

20-
// Just get the index of the current match to avoid rerenders
21-
// as much as possible
22-
const matchIndex = useMatch({
23-
strict: false,
24-
select: (match) => match.index,
25-
})
20+
const { getFromPath, activeLocation } = useActiveLocation()
2621

2722
return React.useCallback(
2823
(options: NavigateOptions) => {
29-
const from =
30-
options.from ??
31-
_defaultOpts?.from ??
32-
state.matches[matchIndex]!.fullPath
24+
const from = getFromPath(options.from ?? _defaultOpts?.from)
3325

34-
return navigate({
26+
return router.navigate({
3527
...options,
3628
from,
3729
})
3830
},
3931
// eslint-disable-next-line react-hooks/exhaustive-deps
40-
[_defaultOpts?.from, navigate],
32+
[_defaultOpts?.from, router, getFromPath, activeLocation],
4133
) as UseNavigateResult<TDefaultFrom>
4234
}
4335

0 commit comments

Comments
 (0)