From 0422f0ec938c633425cc524c7ddbcca8e9122c85 Mon Sep 17 00:00:00 2001 From: Nico Lynzaad Date: Tue, 12 Aug 2025 02:57:07 +0200 Subject: [PATCH 1/5] ensure fromPath caters for basePath --- packages/react-router/tests/link.test.tsx | 109 ++++++++++++++++++++++ packages/router-core/src/router.ts | 4 +- 2 files changed, 112 insertions(+), 1 deletion(-) diff --git a/packages/react-router/tests/link.test.tsx b/packages/react-router/tests/link.test.tsx index 4a8b15a959..cde34d9a1e 100644 --- a/packages/react-router/tests/link.test.tsx +++ b/packages/react-router/tests/link.test.tsx @@ -751,6 +751,115 @@ describe('Link', () => { expect(updatedFilter).toHaveTextContent('Filter: inactive') }) + test('when navigation to . from /posts while updating search from / and using base path', async () => { + const RootComponent = () => { + return ( + <> +
+ + Update Search + +
+ + + ) + } + + const rootRoute = createRootRoute({ + component: RootComponent, + }) + + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: () => { + return ( + <> +

Index

+ + Go to Posts + + + ) + }, + }) + + const PostsComponent = () => { + const search = useSearch({ strict: false }) + return ( + <> +

Posts

+ Page: {search.page} + Filter: {search.filter} + + ) + } + + const postsRoute = createRoute({ + getParentRoute: () => rootRoute, + path: 'posts', + validateSearch: (input: Record) => { + return { + page: input.page ? Number(input.page) : 1, + filter: (input.filter as string) || 'all', + } + }, + component: PostsComponent, + }) + + const router = createRouter({ + routeTree: rootRoute.addChildren([indexRoute, postsRoute]), + history, + }) + + render() + + // Start at index page + const toPostsLink = await screen.findByTestId('to-posts') + expect(toPostsLink).toHaveAttribute('href', '/Dashboard/posts?page=1&filter=active') + + // Navigate to posts with initial search params + await act(() => fireEvent.click(toPostsLink)) + + // Verify we're on posts with initial search + const postsHeading = await screen.findByRole('heading', { name: 'Posts' }) + expect(postsHeading).toBeInTheDocument() + expect(window.location.pathname).toBe('/Dashboard/posts') + expect(window.location.search).toBe('?page=1&filter=active') + + const currentPage = await screen.findByTestId('current-page') + const currentFilter = await screen.findByTestId('current-filter') + expect(currentPage).toHaveTextContent('Page: 1') + expect(currentFilter).toHaveTextContent('Filter: active') + + // Navigate to current route (.) with updated search + const updateSearchLink = await screen.findByTestId('update-search') + + expect(updateSearchLink).toHaveAttribute( + 'href', + '/Dashboard/posts?page=2&filter=inactive', + ) + + await act(() => fireEvent.click(updateSearchLink)) + + // Verify search was updated + expect(window.location.pathname).toBe('/Dashboard/posts') + expect(window.location.search).toBe('?page=2&filter=inactive') + + const updatedPage = await screen.findByTestId('current-page') + const updatedFilter = await screen.findByTestId('current-filter') + expect(updatedPage).toHaveTextContent('Page: 2') + expect(updatedFilter).toHaveTextContent('Filter: inactive') + }) + test('when navigating to /posts with invalid search', async () => { const rootRoute = createRootRoute() const onError = vi.fn() diff --git a/packages/router-core/src/router.ts b/packages/router-core/src/router.ts index 58b996d866..cc5776882e 100644 --- a/packages/router-core/src/router.ts +++ b/packages/router-core/src/router.ts @@ -1420,7 +1420,7 @@ export class RouterCore< // First let's find the starting pathname // By default, start with the current location - let fromPath = lastMatch.fullPath + let fromPath = this.resolvePathWithBase(lastMatch.fullPath, '.') const toPath = dest.to ? this.resolvePathWithBase(fromPath, `${dest.to}`) : this.resolvePathWithBase(fromPath, '.') @@ -1461,6 +1461,8 @@ export class RouterCore< } } + fromPath = this.resolvePathWithBase(fromPath, '.') + // From search should always use the current location const fromSearch = lastMatch.search // Same with params. It can't hurt to provide as many as possible From a26d7ed4461dc2460e5cd14541f0aed032ebccaf Mon Sep 17 00:00:00 2001 From: Nico Lynzaad Date: Tue, 12 Aug 2025 11:56:27 +0200 Subject: [PATCH 2/5] track if "from" is explicitly defined --- packages/react-router/src/link.tsx | 3 ++- packages/react-router/src/useNavigate.tsx | 1 + packages/router-core/src/RouterProvider.ts | 4 +++- packages/router-core/src/router.ts | 7 +++++-- packages/solid-router/src/link.tsx | 1 + packages/solid-router/src/useNavigate.tsx | 3 ++- 6 files changed, 14 insertions(+), 5 deletions(-) diff --git a/packages/react-router/src/link.tsx b/packages/react-router/src/link.tsx index c72d0e8567..975d8295a1 100644 --- a/packages/react-router/src/link.tsx +++ b/packages/react-router/src/link.tsx @@ -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, @@ -284,7 +285,7 @@ export function useLinkProps< hashScrollIntoView, startTransition, viewTransition, - ignoreBlocker, + ignoreBlocker }) } } diff --git a/packages/react-router/src/useNavigate.tsx b/packages/react-router/src/useNavigate.tsx index 1fcef97967..c75a96050c 100644 --- a/packages/react-router/src/useNavigate.tsx +++ b/packages/react-router/src/useNavigate.tsx @@ -34,6 +34,7 @@ export function useNavigate< return navigate({ ...options, from, + _isDefinedFrom: !!options.from, }) }, // eslint-disable-next-line react-hooks/exhaustive-deps diff --git a/packages/router-core/src/RouterProvider.ts b/packages/router-core/src/RouterProvider.ts index 627ddcaae8..4944ab3efc 100644 --- a/packages/router-core/src/RouterProvider.ts +++ b/packages/router-core/src/RouterProvider.ts @@ -29,7 +29,9 @@ export type NavigateFn = < TMaskFrom extends RoutePaths | string = TFrom, TMaskTo extends string = '', >( - opts: NavigateOptions, + opts: NavigateOptions & { + _isDefinedFrom?: boolean + }, ) => Promise export type BuildLocationFn = < diff --git a/packages/router-core/src/router.ts b/packages/router-core/src/router.ts index cc5776882e..c48992d624 100644 --- a/packages/router-core/src/router.ts +++ b/packages/router-core/src/router.ts @@ -461,6 +461,7 @@ export interface BuildNextOptions { _fromLocation?: ParsedLocation unsafeRelative?: 'path' _isNavigate?: boolean + _isDefinedFrom?: boolean } type NavigationEventInfo = { @@ -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 = @@ -1437,7 +1440,7 @@ 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, diff --git a/packages/solid-router/src/link.tsx b/packages/solid-router/src/link.tsx index 008483ac0d..b0d79def0f 100644 --- a/packages/solid-router/src/link.tsx +++ b/packages/solid-router/src/link.tsx @@ -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, diff --git a/packages/solid-router/src/useNavigate.tsx b/packages/solid-router/src/useNavigate.tsx index 96d1207533..212ad0f3c8 100644 --- a/packages/solid-router/src/useNavigate.tsx +++ b/packages/solid-router/src/useNavigate.tsx @@ -44,7 +44,8 @@ export function Navigate< Solid.onMount(() => { navigate({ - ...props, + _isDefinedFrom: !!props.from, + ...props }) }) From ca405e0d44054bdc6efda7a3ed1a0c6771908e92 Mon Sep 17 00:00:00 2001 From: Nico Lynzaad Date: Tue, 12 Aug 2025 11:56:39 +0200 Subject: [PATCH 3/5] add test --- .../react-router/tests/useNavigate.test.tsx | 234 ++++++++++++++++++ 1 file changed, 234 insertions(+) diff --git a/packages/react-router/tests/useNavigate.test.tsx b/packages/react-router/tests/useNavigate.test.tsx index 8d98abdf28..3da77ac943 100644 --- a/packages/react-router/tests/useNavigate.test.tsx +++ b/packages/react-router/tests/useNavigate.test.tsx @@ -1463,6 +1463,240 @@ 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 ( + <> +

Index

+ + + ) + } + + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: IndexComponent, + }) + + const layoutRoute = createRoute({ + getParentRoute: () => rootRoute, + id: '_layout', + component: () => { + return ( + <> +

Layout

+ + + ) + }, + }) + + const PostsComponent = () => { + const navigate = postsRoute.useNavigate() + return ( + <> +

Posts

+ + + + + + ) + } + + const PostDetailComponent = () => { + const navigate = postDetailRoute.useNavigate() + return ( + <> +

Post Detail

+ + + + + + ) + } + + const PostInfoComponent = () => { + return ( + <> +

Post Info

+ + ) + } + + const PostNotesComponent = () => { + return ( + <> +

Post Notes

+ + ) + } + + 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() + + 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) => { From 5fd9400569380c4f7d8916ebc2a1d50c43528ccd Mon Sep 17 00:00:00 2001 From: Nico Lynzaad Date: Tue, 12 Aug 2025 11:56:51 +0200 Subject: [PATCH 4/5] update docs --- docs/router/framework/react/guide/navigation.md | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/docs/router/framework/react/guide/navigation.md b/docs/router/framework/react/guide/navigation.md index 26b8a02ce3..d369331fa3 100644 --- a/docs/router/framework/react/guide/navigation.md +++ b/docs/router/framework/react/guide/navigation.md @@ -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')({ @@ -214,7 +216,16 @@ function PostComponent() { return (
Reload the current route of /posts/$postId - Navigate to /posts + Navigate back to /posts + + // the below are all equivalent + Navigate back to /posts + Navigate back to /posts + + // the below are all equivalent + + Navigate to root + Navigate to root From fbe8fabfab71d5cb0467c5f7e74ae246812a31f6 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Tue, 12 Aug 2025 21:18:46 +0000 Subject: [PATCH 5/5] ci: apply automated fixes --- .../framework/react/guide/navigation.md | 10 +++--- packages/react-router/src/link.tsx | 2 +- .../react-router/tests/useNavigate.test.tsx | 32 ++++++++++++------- packages/router-core/src/router.ts | 6 +++- packages/solid-router/src/useNavigate.tsx | 2 +- 5 files changed, 31 insertions(+), 21 deletions(-) diff --git a/docs/router/framework/react/guide/navigation.md b/docs/router/framework/react/guide/navigation.md index d369331fa3..204d84cccf 100644 --- a/docs/router/framework/react/guide/navigation.md +++ b/docs/router/framework/react/guide/navigation.md @@ -217,15 +217,13 @@ function PostComponent() {
Reload the current route of /posts/$postId Navigate back to /posts - // the below are all equivalent Navigate back to /posts - Navigate back to /posts - - // the below are all equivalent - - Navigate to root + + Navigate back to /posts + // the below are all equivalent + Navigate to root Navigate to root diff --git a/packages/react-router/src/link.tsx b/packages/react-router/src/link.tsx index 975d8295a1..bf3f419beb 100644 --- a/packages/react-router/src/link.tsx +++ b/packages/react-router/src/link.tsx @@ -285,7 +285,7 @@ export function useLinkProps< hashScrollIntoView, startTransition, viewTransition, - ignoreBlocker + ignoreBlocker, }) } } diff --git a/packages/react-router/tests/useNavigate.test.tsx b/packages/react-router/tests/useNavigate.test.tsx index 3da77ac943..7c6d9669be 100644 --- a/packages/react-router/tests/useNavigate.test.tsx +++ b/packages/react-router/tests/useNavigate.test.tsx @@ -1535,7 +1535,7 @@ test.each([true, false])( onClick={() => navigate({ from: '/posts', - to: '.' + to: '.', }) } > @@ -1576,7 +1576,7 @@ test.each([true, false])( onClick={() => navigate({ from: '/posts/$postId', - to: '.' + to: '.', }) } > @@ -1630,7 +1630,11 @@ test.each([true, false])( const router = createRouter({ routeTree: rootRoute.addChildren([ indexRoute, - layoutRoute.addChildren([postsRoute.addChildren([postDetailRoute.addChildren([postInfoRoute, postNotesRoute])])]), + layoutRoute.addChildren([ + postsRoute.addChildren([ + postDetailRoute.addChildren([postInfoRoute, postNotesRoute]), + ]), + ]), ]), trailingSlash: trailingSlash ? 'always' : 'never', }) @@ -1648,7 +1652,9 @@ test.each([true, false])( fireEvent.click(firstPostButton) - expect(await screen.findByTestId('post-detail-index-heading')).toBeInTheDocument() + 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') @@ -1658,14 +1664,16 @@ test.each([true, false])( 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') + 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() + 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') @@ -1680,9 +1688,7 @@ test.each([true, false])( fireEvent.click(toPostsIndexButton) expect(await screen.findByTestId('posts-index-heading')).toBeInTheDocument() - expect( - screen.queryByTestId("'post-notes-heading"), - ).not.toBeInTheDocument() + expect(screen.queryByTestId("'post-notes-heading")).not.toBeInTheDocument() expect( screen.queryByTestId("'post-detail-index-heading"), ).not.toBeInTheDocument() @@ -1692,7 +1698,9 @@ test.each([true, false])( fireEvent.click(secondPostButton) - expect(await screen.findByTestId('post-detail-index-heading')).toBeInTheDocument() + expect( + await screen.findByTestId('post-detail-index-heading'), + ).toBeInTheDocument() expect(window.location.pathname).toEqual(`/posts/2${tail}`) }, ) diff --git a/packages/router-core/src/router.ts b/packages/router-core/src/router.ts index c48992d624..9cd1dbf355 100644 --- a/packages/router-core/src/router.ts +++ b/packages/router-core/src/router.ts @@ -1440,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 && dest._isDefinedFrom ) { + if ( + process.env.NODE_ENV !== 'production' && + dest._isNavigate && + dest._isDefinedFrom + ) { const allFromMatches = this.getMatchedRoutes( dest.from, undefined, diff --git a/packages/solid-router/src/useNavigate.tsx b/packages/solid-router/src/useNavigate.tsx index 212ad0f3c8..f8e653e835 100644 --- a/packages/solid-router/src/useNavigate.tsx +++ b/packages/solid-router/src/useNavigate.tsx @@ -45,7 +45,7 @@ export function Navigate< Solid.onMount(() => { navigate({ _isDefinedFrom: !!props.from, - ...props + ...props, }) })