From b8af5756c31071ea2df5247702b699be5c5fed3c Mon Sep 17 00:00:00 2001 From: Nico Lynzaad Date: Sun, 17 Aug 2025 03:30:24 +0200 Subject: [PATCH 01/25] enhance relative navigation from determination --- packages/react-router/src/link.tsx | 82 +++++++++++++---------- packages/react-router/src/useNavigate.tsx | 13 ++-- packages/router-core/src/router.ts | 20 ++++-- 3 files changed, 72 insertions(+), 43 deletions(-) diff --git a/packages/react-router/src/link.tsx b/packages/react-router/src/link.tsx index c72d0e85678..69e55d114fe 100644 --- a/packages/react-router/src/link.tsx +++ b/packages/react-router/src/link.tsx @@ -99,19 +99,29 @@ export function useLinkProps< structuralSharing: true as any, }) - const from = useMatch({ + const matchIndex = useMatch({ strict: false, - select: (match) => options.from ?? match.fullPath, + select: (match) => match.index, }) + const getFrom = React.useCallback( () => { + const currentRouteMatches= router.matchRoutes(router.latestLocation, { + _buildLocation: false, + }) + + return options.from ?? + currentRouteMatches.slice(-1)[0]?.fullPath ?? + router.state.matches[matchIndex]!.fullPath + }, [router, options.from, matchIndex]) + const next = React.useMemo( - () => router.buildLocation({ ...options, from } as any), + () => router.buildLocation({ ...options, from: getFrom() } as any), // eslint-disable-next-line react-hooks/exhaustive-deps [ router, currentSearch, options._fromLocation, - from, + options.from, options.hash, options.to, options.search, @@ -182,7 +192,7 @@ export function useLinkProps< const doPreload = React.useCallback( () => { - router.preloadRoute({ ...options, from } as any).catch((err) => { + router.preloadRoute({ ...options, from: getFrom() } as any).catch((err) => { console.warn(err) console.warn(preloadWarning) }) @@ -192,7 +202,7 @@ export function useLinkProps< router, options.to, options._fromLocation, - from, + options.from, options.search, options.hash, options.params, @@ -235,24 +245,18 @@ export function useLinkProps< } }, [disabled, doPreload, preload]) - if (isExternal) { - return { - ...propsSafeToSpread, - ref: innerRef as React.ComponentPropsWithRef<'a'>['ref'], - type, - href: to, - ...(children && { children }), - ...(target && { target }), - ...(disabled && { disabled }), - ...(style && { style }), - ...(className && { className }), - ...(onClick && { onClick }), - ...(onFocus && { onFocus }), - ...(onMouseEnter && { onMouseEnter }), - ...(onMouseLeave && { onMouseLeave }), - ...(onTouchStart && { onTouchStart }), - } - } + const navigate = React.useCallback(() => { + router.navigate({ + ...options, + from: getFrom(), + replace, + resetScroll, + hashScrollIntoView, + startTransition, + viewTransition, + ignoreBlocker, + }) + }, [router, options, getFrom, replace, resetScroll, hashScrollIntoView, startTransition, viewTransition, ignoreBlocker]) // The click handler const handleClick = (e: React.MouseEvent) => { @@ -276,16 +280,26 @@ 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({ - ...options, - from, - replace, - resetScroll, - hashScrollIntoView, - startTransition, - viewTransition, - ignoreBlocker, - }) + navigate() + } + } + + if (isExternal) { + return { + ...propsSafeToSpread, + ref: innerRef as React.ComponentPropsWithRef<'a'>['ref'], + type, + href: to, + ...(children && { children }), + ...(target && { target }), + ...(disabled && { disabled }), + ...(style && { style }), + ...(className && { className }), + ...(onClick && { onClick }), + ...(onFocus && { onFocus }), + ...(onMouseEnter && { onMouseEnter }), + ...(onMouseLeave && { onMouseLeave }), + ...(onTouchStart && { onTouchStart }), } } diff --git a/packages/react-router/src/useNavigate.tsx b/packages/react-router/src/useNavigate.tsx index 1fcef979673..b4a1e1d395c 100644 --- a/packages/react-router/src/useNavigate.tsx +++ b/packages/react-router/src/useNavigate.tsx @@ -15,7 +15,7 @@ export function useNavigate< >(_defaultOpts?: { from?: FromPathOption }): UseNavigateResult { - const { navigate, state } = useRouter() + const router = useRouter() // Just get the index of the current match to avoid rerenders // as much as possible @@ -26,18 +26,23 @@ export function useNavigate< return React.useCallback( (options: NavigateOptions) => { + const currentRouteMatches= router.matchRoutes(router.latestLocation, { + _buildLocation: false, + }) + const from = options.from ?? _defaultOpts?.from ?? - state.matches[matchIndex]!.fullPath + currentRouteMatches.slice(-1)[0]?.fullPath ?? + router.state.matches[matchIndex]!.fullPath - return navigate({ + return router.navigate({ ...options, from, }) }, // eslint-disable-next-line react-hooks/exhaustive-deps - [_defaultOpts?.from, navigate], + [_defaultOpts?.from, router.navigate, router.latestLocation], ) as UseNavigateResult } diff --git a/packages/router-core/src/router.ts b/packages/router-core/src/router.ts index c4409961b83..2c602f00e5d 100644 --- a/packages/router-core/src/router.ts +++ b/packages/router-core/src/router.ts @@ -1419,14 +1419,24 @@ export class RouterCore< // First let's find the starting pathname // By default, start with the current location let fromPath = this.resolvePathWithBase(lastMatch.fullPath, '.') - const toPath = dest.to - ? this.resolvePathWithBase(fromPath, `${dest.to}`) - : this.resolvePathWithBase(fromPath, '.') + const destFromPath = dest.from && this.resolvePathWithBase(dest.from, '.') + + const toPath = destFromPath + ? this.resolvePathWithBase(destFromPath, `${dest.to ?? "."}`) + : this.resolvePathWithBase(fromPath, `${dest.to ?? "."}`) const routeIsChanging = !!dest.to && - !comparePaths(dest.to.toString(), fromPath) && - !comparePaths(toPath, fromPath) + ( + comparePaths(destFromPath ?? fromPath, fromPath) ? + ( + !comparePaths(toPath, fromPath) + ) : + ( + !comparePaths(toPath, destFromPath!) || + !comparePaths(toPath, fromPath) + ) + ) // If the route is changing we need to find the relative fromPath if (dest.unsafeRelative === 'path') { From c53266f8f64849710e8bee8f70c14ebf49fa6547 Mon Sep 17 00:00:00 2001 From: Nico Lynzaad Date: Sun, 17 Aug 2025 03:31:02 +0200 Subject: [PATCH 02/25] update react-router tests --- packages/react-router/tests/link.test.tsx | 715 +++++++++++++++++- .../react-router/tests/useNavigate.test.tsx | 433 ++++++++++- 2 files changed, 1143 insertions(+), 5 deletions(-) diff --git a/packages/react-router/tests/link.test.tsx b/packages/react-router/tests/link.test.tsx index 92c215a0e9a..90126ac5b3b 100644 --- a/packages/react-router/tests/link.test.tsx +++ b/packages/react-router/tests/link.test.tsx @@ -33,6 +33,7 @@ import { useRouteContext, useRouterState, useSearch, + getRouteApi, } from '../src' import { getIntersectionObserverMock, @@ -5073,7 +5074,7 @@ describe.each([{ basepath: '' }, { basepath: '/basepath' }])( return ( <>

Param Route

- Link to ./a + Link to ./a Link to c @@ -5093,7 +5094,7 @@ describe.each([{ basepath: '' }, { basepath: '/basepath' }])( return ( <>

Param A Route

- Link to .. from /param/foo/a + Link to .. from /param/foo/a ) @@ -5407,3 +5408,713 @@ describe.each([{ basepath: '' }, { basepath: '/basepath' }])( }) }, ) + +test.each([true, false])( + 'should navigate to current route when using "." in nested route structure from Index Route', + async (trailingSlash: boolean) => { + const tail = trailingSlash ? '/' : '' + + const rootRoute = createRootRoute() + + const IndexComponent = () => { + return ( + <> + + Post + + + Search + + + Search2 + + + + ) + } + + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: IndexComponent, + validateSearch: z.object({ + param1: z.string().optional(), + }), + }) + + const postRoute = createRoute({ + getParentRoute: () => indexRoute, + path: 'post', + component: () => ( + <> +
Post
+ + ), + }) + + const router = createRouter({ + routeTree: rootRoute.addChildren([indexRoute, postRoute]), + history, + trailingSlash: trailingSlash ? 'always' : 'never', + }) + + render() + + const postButton = await screen.findByTestId('posts-link') + const searchButton = await screen.findByTestId('search-link') + const searchButton2 = await screen.findByTestId('search2-link') + + await act(() => fireEvent.click(postButton)) + + expect(window.location.pathname).toBe(`/post${tail}`) + + await act(() => fireEvent.click(searchButton)) + + expect(router.state.location.pathname).toBe(`/post${tail}`) + expect(router.state.location.search).toEqual({ param1: 'value1' }) + + await act(() => fireEvent.click(searchButton2)) + + expect(router.state.location.pathname).toBe(`/post${tail}`) + expect(router.state.location.search).toEqual({ param1: 'value2' }) + }, +) + +test.each([true, false])( + 'should navigate to current route with changing path params when using "." in nested route structure', + async (trailingSlash) => { + const tail = trailingSlash ? '/' : '' + const rootRoute = createRootRoute() + + const IndexComponent = () => { + return ( + <> +

Index

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

Layout

+ + + ) + }, + }) + + const PostsComponent = () => { + return ( + <> +

Posts

+ + To first post + + + To second post + + + + ) + } + + const postsRoute = createRoute({ + getParentRoute: () => layoutRoute, + path: 'posts', + component: PostsComponent, + }) + + const PostComponent = () => { + const params = useParams({ strict: false }) + return ( + <> + + Params: {params.postId} + + + ) + } + + const postRoute = createRoute({ + getParentRoute: () => postsRoute, + path: '$postId', + component: PostComponent, + }) + + const router = createRouter({ + routeTree: rootRoute.addChildren([ + indexRoute, + layoutRoute.addChildren([postsRoute.addChildren([postRoute])]), + ]), + trailingSlash: trailingSlash ? 'always' : 'never', + }) + + render() + + const postsButton = await screen.findByTestId('posts-link') + + await act(() => 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-link') + + await act(() => fireEvent.click(firstPostButton)) + + expect(await screen.findByTestId('post-id1')).toBeInTheDocument() + expect(window.location.pathname).toEqual(`/posts/id1${tail}`) + + const secondPostButton = await screen.findByTestId('second-post-link') + + await act(() => fireEvent.click(secondPostButton)) + + // expect(await screen.findByTestId('post-id2')).toBeInTheDocument() + expect(window.location.pathname).toEqual(`/posts/id2${tail}`) + }, +) + +test.each([true, false])( + 'should navigate to current route with search params when using "." in nested route structure from Index Route', + async (trailingSlash: boolean) => { + const tail = trailingSlash ? '/' : '' + + const rootRoute = createRootRoute() + + const IndexComponent = () => { + return ( + <> + + Post + + + Search + + + Search2 + + + + ) + } + + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: IndexComponent, + validateSearch: z.object({ + param1: z.string().optional(), + }), + }) + + const postRoute = createRoute({ + getParentRoute: () => indexRoute, + path: 'post', + component: () =>
Post
, + }) + + const router = createRouter({ + routeTree: rootRoute.addChildren([indexRoute, postRoute]), + history, + trailingSlash: trailingSlash ? 'always' : 'never', + }) + + render() + + const postButton = await screen.findByTestId('posts-link') + + await act(() => fireEvent.click(postButton)) + + expect(window.location.pathname).toBe(`/post${tail}`) + + const searchButton = await screen.findByTestId('search-link') + + await act(() => fireEvent.click(searchButton)) + + expect(router.state.location.pathname).toBe(`/post${tail}`) + expect(router.state.location.search).toEqual({ param1: 'value1' }) + + const searchButton2 = await screen.findByTestId('search2-link') + + await act(() => fireEvent.click(searchButton2)) + + expect(router.state.location.pathname).toBe(`/post${tail}`) + expect(router.state.location.search).toEqual({ param1: 'value2' }) + }, +) + +test.each([true, false])( + 'should navigate to from route when using "." in nested route structure from Index Route', + async (trailingSlash: boolean) => { + const tail = trailingSlash ? '/' : '' + + const rootRoute = createRootRoute() + + const IndexComponent = () => { + return ( + <> + + Post + + + Search + + + Go To Home + + + + ) + } + + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: IndexComponent, + validateSearch: z.object({ + param1: z.string().optional(), + }), + }) + + const postRoute = createRoute({ + getParentRoute: () => indexRoute, + path: 'post', + component: () =>
Post
, + }) + + const router = createRouter({ + routeTree: rootRoute.addChildren([indexRoute, postRoute]), + history, + trailingSlash: trailingSlash ? 'always' : 'never', + }) + + render() + + const postButton = await screen.findByTestId('posts-link') + + fireEvent.click(postButton) + + expect(router.state.location.pathname).toBe(`/post${tail}`) + + const searchButton = await screen.findByTestId('search-link') + + fireEvent.click(searchButton) + + expect(router.state.location.pathname).toBe(`/post${tail}`) + expect(router.state.location.search).toEqual({ param1: 'value1' }) + + const homeBtn = await screen.findByTestId('home-link') + + fireEvent.click(homeBtn) + + expect(router.state.location.pathname).toBe(`/`) + expect(router.state.location.search).toEqual({}) + }, +) + +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 = () => { + return ( + <> +

Index

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

Layout

+ + + ) + }, + }) + + const PostsComponent = () => { + return ( + <> +

Posts

+ + To first post + + + To second post + + + To posts list + + + + ) + } + + const PostDetailComponent = () => { + const navigate = postDetailRoute.useNavigate() + return ( + <> +

Post Detail

+ + To post info + + + To post notes + + + To index detail options + + + + ) + } + + 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-link') + + 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-link') + + 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-link') + + 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-link') + + 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-link') + + 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-link') + + 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-link') + + fireEvent.click(secondPostButton) + + expect(await screen.findByTestId('post-detail-index-heading')).toBeInTheDocument() + expect(window.location.pathname).toEqual(`/posts/2${tail}`) + }, +) + +describe('when on /posts/$postId and navigating to ../ with default `from` /posts', () => { + async function runTest(navigateVia: 'Route' | 'RouteApi') { + const rootRoute = createRootRoute() + + const IndexComponent = () => { + return ( + <> +

Index

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

Layout

+ + + ) + }, + }) + + const PostsComponent = () => { + const linkVia = () => { + if (navigateVia === 'Route') { + return To Home + } + + const RouteApiLink = getRouteApi('/_layout/posts').Link + + return To Home + } + + return ( + <> +

Posts

+ {linkVia()} + + + ) + } + + const postsRoute = createRoute({ + getParentRoute: () => layoutRoute, + path: 'posts', + component: PostsComponent, + }) + + const PostComponent = () => { + const params = useParams({ strict: false }) + return ( + <> + Params: {params.postId} + + + ) + } + + const postRoute = createRoute({ + getParentRoute: () => postsRoute, + path: '$postId', + component: PostComponent, + }) + + const PostIndexComponent = () => { + return ( + <> +

Post Index

+ + ) + } + + const postIndexRoute = createRoute({ + getParentRoute: () => postRoute, + path: '/', + component: PostIndexComponent, + }) + + const DetailsComponent = () => { + return ( + <> +

Details!

+ + ) + } + + const detailsRoute = createRoute({ + getParentRoute: () => postRoute, + path: 'details', + component: DetailsComponent, + }) + + const router = createRouter({ + routeTree: rootRoute.addChildren([ + indexRoute, + layoutRoute.addChildren([ + postsRoute.addChildren([ + postRoute.addChildren([postIndexRoute, detailsRoute]), + ]), + ]), + ]), + }) + + render() + + const postsButton = await screen.findByTestId('index-to-first-post-link') + + fireEvent.click(postsButton) + + expect(await screen.findByTestId('details-heading')).toBeInTheDocument() + + expect(window.location.pathname).toEqual('/posts/id1/details') + + const homeButton = await screen.findByTestId('link-to-home') + + fireEvent.click(homeButton) + + expect(await screen.findByTestId('index-heading')).toBeInTheDocument() + expect(window.location.pathname).toEqual('/') + } + + test('Route', () => runTest('Route')) + test('RouteApi', () => runTest('RouteApi')) +}) diff --git a/packages/react-router/tests/useNavigate.test.tsx b/packages/react-router/tests/useNavigate.test.tsx index 8d98abdf286..07b84d694fe 100644 --- a/packages/react-router/tests/useNavigate.test.tsx +++ b/packages/react-router/tests/useNavigate.test.tsx @@ -1504,7 +1504,8 @@ test.each([true, false])( }) const PostsComponent = () => { - const navigate = postsRoute.useNavigate() + const navigate = useNavigate() + return ( <>

Posts

@@ -1776,6 +1777,432 @@ test.each([true, false])( }, ) +test.each([true, false])( + 'should navigate to current route with search params when using "." in nested route structure from Index Route', + async (trailingSlash: boolean) => { + const tail = trailingSlash ? '/' : '' + + const rootRoute = createRootRoute() + + const IndexComponent = () => { + const navigate = useNavigate() + return ( + <> + + + + + + ) + } + + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: IndexComponent, + validateSearch: z.object({ + param1: z.string().optional(), + }), + }) + + const postRoute = createRoute({ + getParentRoute: () => indexRoute, + path: 'post', + component: () =>
Post
, + }) + + const router = createRouter({ + routeTree: rootRoute.addChildren([indexRoute, postRoute]), + history, + trailingSlash: trailingSlash ? 'always' : 'never', + }) + + render() + + const postButton = await screen.findByTestId('posts-btn') + + fireEvent.click(postButton) + + expect(router.state.location.pathname).toBe(`/post${tail}`) + + const searchButton = await screen.findByTestId('search-btn') + + fireEvent.click(searchButton) + + expect(router.state.location.pathname).toBe(`/post${tail}`) + expect(router.state.location.search).toEqual({ param1: 'value1' }) + + const searchButton2 = await screen.findByTestId('search2-btn') + + fireEvent.click(searchButton2) + + expect(router.state.location.pathname).toBe(`/post${tail}`) + expect(router.state.location.search).toEqual({ param1: 'value2' }) + }, +) + +test.each([true, false])( + 'should navigate to from route when using "." in nested route structure from Index Route', + async (trailingSlash: boolean) => { + const tail = trailingSlash ? '/' : '' + + const rootRoute = createRootRoute() + + const IndexComponent = () => { + const navigate = useNavigate() + return ( + <> + + + + + + ) + } + + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: IndexComponent, + validateSearch: z.object({ + param1: z.string().optional(), + }), + }) + + const postRoute = createRoute({ + getParentRoute: () => indexRoute, + path: 'post', + component: () =>
Post
, + }) + + const router = createRouter({ + routeTree: rootRoute.addChildren([indexRoute, postRoute]), + history, + trailingSlash: trailingSlash ? 'always' : 'never', + }) + + render() + + const postButton = await screen.findByTestId('posts-btn') + + fireEvent.click(postButton) + + expect(router.state.location.pathname).toBe(`/post${tail}`) + + const searchButton = await screen.findByTestId('search-btn') + + fireEvent.click(searchButton) + + expect(router.state.location.pathname).toBe(`/post${tail}`) + expect(router.state.location.search).toEqual({ param1: 'value1' }) + + const homeBtn = await screen.findByTestId('home-btn') + + fireEvent.click(homeBtn) + + expect(router.state.location.pathname).toBe(`/`) + expect(router.state.location.search).toEqual({}) + }, +) + +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}`) + }, +) + describe('when on /posts/$postId and navigating to ../ with default `from` /posts', () => { async function runTest(navigateVia: 'Route' | 'RouteApi') { const rootRoute = createRootRoute() @@ -1975,7 +2402,7 @@ describe.each([{ basepath: '' }, { basepath: '/basepath' }])( return ( <>

Param Route

- From 9e1b1dd47b1e3a4690c2c956973e3f8c981df925 Mon Sep 17 00:00:00 2001 From: Nico Lynzaad Date: Sun, 17 Aug 2025 14:06:19 +0200 Subject: [PATCH 03/25] some more test refining --- packages/react-router/tests/link.test.tsx | 925 +++++++++--------- .../react-router/tests/useNavigate.test.tsx | 128 +-- 2 files changed, 480 insertions(+), 573 deletions(-) diff --git a/packages/react-router/tests/link.test.tsx b/packages/react-router/tests/link.test.tsx index 90126ac5b3b..67ab8a8eb6d 100644 --- a/packages/react-router/tests/link.test.tsx +++ b/packages/react-router/tests/link.test.tsx @@ -24,6 +24,7 @@ import { createRoute, createRouteMask, createRouter, + getRouteApi, redirect, retainSearchParams, stripSearchParams, @@ -33,7 +34,6 @@ import { useRouteContext, useRouterState, useSearch, - getRouteApi, } from '../src' import { getIntersectionObserverMock, @@ -5409,573 +5409,576 @@ describe.each([{ basepath: '' }, { basepath: '/basepath' }])( }, ) -test.each([true, false])( - 'should navigate to current route when using "." in nested route structure from Index Route', - async (trailingSlash: boolean) => { - const tail = trailingSlash ? '/' : '' - - const rootRoute = createRootRoute() +describe('relative links to current route', () => { + test.each([true, false])( + 'should navigate to current route when using "." in nested route structure from Index Route', + async (trailingSlash: boolean) => { + const tail = trailingSlash ? '/' : '' - const IndexComponent = () => { - return ( - <> - - Post - - - Search - - - Search2 - - - - ) - } + const rootRoute = createRootRoute() - const indexRoute = createRoute({ - getParentRoute: () => rootRoute, - path: '/', - component: IndexComponent, - validateSearch: z.object({ - param1: z.string().optional(), - }), - }) + const IndexComponent = () => { + return ( + <> + + Post + + + Search + + + Search2 + + + + ) + } - const postRoute = createRoute({ - getParentRoute: () => indexRoute, - path: 'post', - component: () => ( - <> -
Post
- - ), - }) + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: IndexComponent, + validateSearch: z.object({ + param1: z.string().optional(), + }), + }) - const router = createRouter({ - routeTree: rootRoute.addChildren([indexRoute, postRoute]), - history, - trailingSlash: trailingSlash ? 'always' : 'never', - }) + const postRoute = createRoute({ + getParentRoute: () => indexRoute, + path: 'post', + component: () => ( + <> +
Post
+ + ), + }) - render() + const router = createRouter({ + routeTree: rootRoute.addChildren([indexRoute, postRoute]), + history, + trailingSlash: trailingSlash ? 'always' : 'never', + }) - const postButton = await screen.findByTestId('posts-link') - const searchButton = await screen.findByTestId('search-link') - const searchButton2 = await screen.findByTestId('search2-link') + render() - await act(() => fireEvent.click(postButton)) + const postButton = await screen.findByTestId('posts-link') + const searchButton = await screen.findByTestId('search-link') + const searchButton2 = await screen.findByTestId('search2-link') - expect(window.location.pathname).toBe(`/post${tail}`) + await act(() => fireEvent.click(postButton)) - await act(() => fireEvent.click(searchButton)) + expect(window.location.pathname).toBe(`/post${tail}`) - expect(router.state.location.pathname).toBe(`/post${tail}`) - expect(router.state.location.search).toEqual({ param1: 'value1' }) + await act(() => fireEvent.click(searchButton)) - await act(() => fireEvent.click(searchButton2)) + expect(router.state.location.pathname).toBe(`/post${tail}`) + expect(router.state.location.search).toEqual({ param1: 'value1' }) - expect(router.state.location.pathname).toBe(`/post${tail}`) - expect(router.state.location.search).toEqual({ param1: 'value2' }) - }, -) + await act(() => fireEvent.click(searchButton2)) -test.each([true, false])( - 'should navigate to current route with changing path params when using "." in nested route structure', - async (trailingSlash) => { - const tail = trailingSlash ? '/' : '' - const rootRoute = createRootRoute() + expect(router.state.location.pathname).toBe(`/post${tail}`) + expect(router.state.location.search).toEqual({ param1: 'value2' }) + }, + ) - const IndexComponent = () => { - return ( - <> -

Index

- - Posts - - - ) - } + test.each([true, false])( + 'should navigate to current route with search params when using "." in nested route structure from Index Route', + async (trailingSlash: boolean) => { + const tail = trailingSlash ? '/' : '' - const indexRoute = createRoute({ - getParentRoute: () => rootRoute, - path: '/', - component: IndexComponent, - }) + const rootRoute = createRootRoute() - const layoutRoute = createRoute({ - getParentRoute: () => rootRoute, - id: '_layout', - component: () => { + const IndexComponent = () => { return ( <> -

Layout

+ + Post + + + Search + + + Search2 + ) - }, - }) + } - const PostsComponent = () => { - return ( - <> -

Posts

- - To first post - - - To second post - - - - ) - } + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: IndexComponent, + validateSearch: z.object({ + param1: z.string().optional(), + }), + }) - const postsRoute = createRoute({ - getParentRoute: () => layoutRoute, - path: 'posts', - component: PostsComponent, - }) + const postRoute = createRoute({ + getParentRoute: () => indexRoute, + path: 'post', + component: () =>
Post
, + }) - const PostComponent = () => { - const params = useParams({ strict: false }) - return ( - <> - - Params: {params.postId} - - - ) - } + const router = createRouter({ + routeTree: rootRoute.addChildren([indexRoute, postRoute]), + history, + trailingSlash: trailingSlash ? 'always' : 'never', + }) - const postRoute = createRoute({ - getParentRoute: () => postsRoute, - path: '$postId', - component: PostComponent, - }) + render() - const router = createRouter({ - routeTree: rootRoute.addChildren([ - indexRoute, - layoutRoute.addChildren([postsRoute.addChildren([postRoute])]), - ]), - trailingSlash: trailingSlash ? 'always' : 'never', - }) + const postButton = await screen.findByTestId('posts-link') - render() + await act(() => fireEvent.click(postButton)) - const postsButton = await screen.findByTestId('posts-link') + expect(window.location.pathname).toBe(`/post${tail}`) - await act(() => fireEvent.click(postsButton)) + const searchButton = await screen.findByTestId('search-link') - expect(await screen.findByTestId('posts-index-heading')).toBeInTheDocument() - expect(window.location.pathname).toEqual(`/posts${tail}`) + await act(() => fireEvent.click(searchButton)) - const firstPostButton = await screen.findByTestId('first-post-link') + expect(router.state.location.pathname).toBe(`/post${tail}`) + expect(router.state.location.search).toEqual({ param1: 'value1' }) - await act(() => fireEvent.click(firstPostButton)) + const searchButton2 = await screen.findByTestId('search2-link') - expect(await screen.findByTestId('post-id1')).toBeInTheDocument() - expect(window.location.pathname).toEqual(`/posts/id1${tail}`) + await act(() => fireEvent.click(searchButton2)) - const secondPostButton = await screen.findByTestId('second-post-link') + expect(router.state.location.pathname).toBe(`/post${tail}`) + expect(router.state.location.search).toEqual({ param1: 'value2' }) + }, + ) - await act(() => fireEvent.click(secondPostButton)) + test.each([true, false])( + 'should navigate to current route with changing path params when using "." in nested route structure', + async (trailingSlash) => { + const tail = trailingSlash ? '/' : '' + const rootRoute = createRootRoute() - // expect(await screen.findByTestId('post-id2')).toBeInTheDocument() - expect(window.location.pathname).toEqual(`/posts/id2${tail}`) - }, -) + const IndexComponent = () => { + return ( + <> +

Index

+ + Posts + + + ) + } -test.each([true, false])( - 'should navigate to current route with search params when using "." in nested route structure from Index Route', - async (trailingSlash: boolean) => { - const tail = trailingSlash ? '/' : '' + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: IndexComponent, + }) - const rootRoute = createRootRoute() + const layoutRoute = createRoute({ + getParentRoute: () => rootRoute, + id: '_layout', + component: () => { + return ( + <> +

Layout

+ + + ) + }, + }) - const IndexComponent = () => { - return ( - <> - - Post - - - Search - - - Search2 - - - - ) - } + const PostsComponent = () => { + return ( + <> +

Posts

+ + To first post + + + To second post + + + + ) + } - const indexRoute = createRoute({ - getParentRoute: () => rootRoute, - path: '/', - component: IndexComponent, - validateSearch: z.object({ - param1: z.string().optional(), - }), - }) + const postsRoute = createRoute({ + getParentRoute: () => layoutRoute, + path: 'posts', + component: PostsComponent, + }) - const postRoute = createRoute({ - getParentRoute: () => indexRoute, - path: 'post', - component: () =>
Post
, - }) + const PostComponent = () => { + const params = useParams({ strict: false }) + return ( + <> + + Params: {params.postId} + + + ) + } - const router = createRouter({ - routeTree: rootRoute.addChildren([indexRoute, postRoute]), - history, - trailingSlash: trailingSlash ? 'always' : 'never', - }) + const postRoute = createRoute({ + getParentRoute: () => postsRoute, + path: '$postId', + component: PostComponent, + }) - render() + const router = createRouter({ + routeTree: rootRoute.addChildren([ + indexRoute, + layoutRoute.addChildren([postsRoute.addChildren([postRoute])]), + ]), + trailingSlash: trailingSlash ? 'always' : 'never', + }) - const postButton = await screen.findByTestId('posts-link') + render() - await act(() => fireEvent.click(postButton)) + const postsButton = await screen.findByTestId('posts-link') - expect(window.location.pathname).toBe(`/post${tail}`) + await act(() => fireEvent.click(postsButton)) - const searchButton = await screen.findByTestId('search-link') + expect(await screen.findByTestId('posts-index-heading')).toBeInTheDocument() + expect(window.location.pathname).toEqual(`/posts${tail}`) - await act(() => fireEvent.click(searchButton)) + const firstPostButton = await screen.findByTestId('first-post-link') - expect(router.state.location.pathname).toBe(`/post${tail}`) - expect(router.state.location.search).toEqual({ param1: 'value1' }) + await act(() => fireEvent.click(firstPostButton)) - const searchButton2 = await screen.findByTestId('search2-link') + expect(await screen.findByTestId('post-id1')).toBeInTheDocument() + expect(window.location.pathname).toEqual(`/posts/id1${tail}`) - await act(() => fireEvent.click(searchButton2)) + const secondPostButton = await screen.findByTestId('second-post-link') - expect(router.state.location.pathname).toBe(`/post${tail}`) - expect(router.state.location.search).toEqual({ param1: 'value2' }) - }, -) + await act(() => fireEvent.click(secondPostButton)) -test.each([true, false])( - 'should navigate to from route when using "." in nested route structure from Index Route', - async (trailingSlash: boolean) => { - const tail = trailingSlash ? '/' : '' + // expect(await screen.findByTestId('post-id2')).toBeInTheDocument() + expect(window.location.pathname).toEqual(`/posts/id2${tail}`) + }, + ) +}) - const rootRoute = createRootRoute() +describe('relative links to from route', () => { + test.each([true, false])( + 'should navigate to from route when using "." in nested route structure from Index Route', + async (trailingSlash: boolean) => { + const tail = trailingSlash ? '/' : '' - const IndexComponent = () => { - return ( - <> - - Post - - - Search - - - Go To Home - - - - ) - } + const rootRoute = createRootRoute() - const indexRoute = createRoute({ - getParentRoute: () => rootRoute, - path: '/', - component: IndexComponent, - validateSearch: z.object({ - param1: z.string().optional(), - }), - }) + const IndexComponent = () => { + return ( + <> + + Post + + + Search + + + Go To Home + + + + ) + } - const postRoute = createRoute({ - getParentRoute: () => indexRoute, - path: 'post', - component: () =>
Post
, - }) + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: IndexComponent, + validateSearch: z.object({ + param1: z.string().optional(), + }), + }) - const router = createRouter({ - routeTree: rootRoute.addChildren([indexRoute, postRoute]), - history, - trailingSlash: trailingSlash ? 'always' : 'never', - }) + const postRoute = createRoute({ + getParentRoute: () => indexRoute, + path: 'post', + component: () =>
Post
, + }) - render() + const router = createRouter({ + routeTree: rootRoute.addChildren([indexRoute, postRoute]), + history, + trailingSlash: trailingSlash ? 'always' : 'never', + }) - const postButton = await screen.findByTestId('posts-link') + render() - fireEvent.click(postButton) + const postButton = await screen.findByTestId('posts-link') - expect(router.state.location.pathname).toBe(`/post${tail}`) + await act(() => fireEvent.click(postButton)) - const searchButton = await screen.findByTestId('search-link') + expect(router.state.location.pathname).toBe(`/post${tail}`) - fireEvent.click(searchButton) + const searchButton = await screen.findByTestId('search-link') - expect(router.state.location.pathname).toBe(`/post${tail}`) - expect(router.state.location.search).toEqual({ param1: 'value1' }) + await act(() => fireEvent.click(searchButton)) - const homeBtn = await screen.findByTestId('home-link') + expect(router.state.location.pathname).toBe(`/post${tail}`) + expect(router.state.location.search).toEqual({ param1: 'value1' }) - fireEvent.click(homeBtn) + const homeBtn = await screen.findByTestId('home-link') - expect(router.state.location.pathname).toBe(`/`) - expect(router.state.location.search).toEqual({}) - }, -) + await act(() => fireEvent.click(homeBtn)) -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() + expect(router.state.location.pathname).toBe(`/`) + expect(router.state.location.search).toEqual({}) + }, + ) - const IndexComponent = () => { - return ( - <> -

Index

- - Posts - - - ) - } + 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 indexRoute = createRoute({ - getParentRoute: () => rootRoute, - path: '/', - component: IndexComponent, - }) + const IndexComponent = () => { + return ( + <> +

Index

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

Layout

+ + + ) + }, + }) + + const PostsComponent = () => { return ( <> -

Layout

+

Posts

+ + To first post + + + To second post + + + To posts list + ) - }, - }) - - const PostsComponent = () => { - return ( - <> -

Posts

- - To first post - - - To second post - - - To posts list - - - - ) - } + } - const PostDetailComponent = () => { - const navigate = postDetailRoute.useNavigate() - return ( - <> -

Post Detail

- - To post info - - - To post notes - - - To index detail options - - - - ) - } + const PostDetailComponent = () => { + return ( + <> +

Post Detail

+ + To post info + + + To post notes + + + To index detail options + + + + ) + } - const PostInfoComponent = () => { - return ( - <> -

Post Info

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

Post Info

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

Post Notes

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

Post Notes

+ + ) + } - const postsRoute = createRoute({ - getParentRoute: () => layoutRoute, - path: 'posts', - component: PostsComponent, - }) + const postsRoute = createRoute({ + getParentRoute: () => layoutRoute, + path: 'posts', + component: PostsComponent, + }) - const postDetailRoute = createRoute({ - getParentRoute: () => postsRoute, - path: '$postId', - component: PostDetailComponent, - }) + const postDetailRoute = createRoute({ + getParentRoute: () => postsRoute, + path: '$postId', + component: PostDetailComponent, + }) - const postInfoRoute = createRoute({ - getParentRoute: () => postDetailRoute, - path: 'info', - component: PostInfoComponent, - }) + const postInfoRoute = createRoute({ + getParentRoute: () => postDetailRoute, + path: 'info', + component: PostInfoComponent, + }) - const postNotesRoute = createRoute({ - getParentRoute: () => postDetailRoute, - path: 'notes', - component: PostNotesComponent, - }) + 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', - }) + const router = createRouter({ + routeTree: rootRoute.addChildren([ + indexRoute, + layoutRoute.addChildren([postsRoute.addChildren([postDetailRoute.addChildren([postInfoRoute, postNotesRoute])])]), + ]), + trailingSlash: trailingSlash ? 'always' : 'never', + }) - render() + render() - const postsButton = await screen.findByTestId('posts-link') + const postsButton = await screen.findByTestId('posts-link') - fireEvent.click(postsButton) + fireEvent.click(postsButton) - expect(await screen.findByTestId('posts-index-heading')).toBeInTheDocument() - expect(window.location.pathname).toEqual(`/posts${tail}`) + expect(await screen.findByTestId('posts-index-heading')).toBeInTheDocument() + expect(window.location.pathname).toEqual(`/posts${tail}`) - const firstPostButton = await screen.findByTestId('first-post-link') + const firstPostButton = await screen.findByTestId('first-post-link') - fireEvent.click(firstPostButton) + fireEvent.click(firstPostButton) - expect(await screen.findByTestId('post-detail-index-heading')).toBeInTheDocument() - expect(window.location.pathname).toEqual(`/posts/1${tail}`) + expect(await screen.findByTestId('post-detail-index-heading')).toBeInTheDocument() + expect(window.location.pathname).toEqual(`/posts/1${tail}`) - const postInfoButton = await screen.findByTestId('post-info-link') + const postInfoButton = await screen.findByTestId('post-info-link') - fireEvent.click(postInfoButton) + fireEvent.click(postInfoButton) - expect(await screen.findByTestId('post-info-heading')).toBeInTheDocument() - expect(window.location.pathname).toEqual(`/posts/1/info${tail}`) + 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-link') + const toPostDetailIndexButton = await screen.findByTestId('to-post-detail-index-link') - fireEvent.click(toPostDetailIndexButton) + 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}`) + 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-link') + const postNotesButton = await screen.findByTestId('post-notes-link') - fireEvent.click(postNotesButton) + fireEvent.click(postNotesButton) - expect(await screen.findByTestId('post-notes-heading')).toBeInTheDocument() - expect(window.location.pathname).toEqual(`/posts/1/notes${tail}`) + 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-link') + const toPostsIndexButton = await screen.findByTestId('to-posts-index-link') - fireEvent.click(toPostsIndexButton) + 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}`) + 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-link') + const secondPostButton = await screen.findByTestId('second-post-link') - fireEvent.click(secondPostButton) + fireEvent.click(secondPostButton) - expect(await screen.findByTestId('post-detail-index-heading')).toBeInTheDocument() - expect(window.location.pathname).toEqual(`/posts/2${tail}`) - }, -) + expect(await screen.findByTestId('post-detail-index-heading')).toBeInTheDocument() + expect(window.location.pathname).toEqual(`/posts/2${tail}`) + }, + ) +}) describe('when on /posts/$postId and navigating to ../ with default `from` /posts', () => { async function runTest(navigateVia: 'Route' | 'RouteApi') { diff --git a/packages/react-router/tests/useNavigate.test.tsx b/packages/react-router/tests/useNavigate.test.tsx index 07b84d694fe..275f2a8e287 100644 --- a/packages/react-router/tests/useNavigate.test.tsx +++ b/packages/react-router/tests/useNavigate.test.tsx @@ -1058,13 +1058,13 @@ test('when navigating from /invoices to ./invoiceId and the current route is /po name: 'To first post', }) - fireEvent.click(postsButton) + await act(() => fireEvent.click(postsButton)) const invoicesButton = await screen.findByRole('button', { name: 'To Invoices', }) - fireEvent.click(invoicesButton) + await act(() => fireEvent.click(invoicesButton)) expect(consoleWarn).toHaveBeenCalledWith( 'Could not find match for from: /invoices', @@ -1443,20 +1443,20 @@ test.each([true, false])( const postButton = await screen.findByTestId('posts-btn') - fireEvent.click(postButton) + await act(() => fireEvent.click(postButton)) expect(router.state.location.pathname).toBe(`/post${tail}`) const searchButton = await screen.findByTestId('search-btn') - fireEvent.click(searchButton) + await act(() => fireEvent.click(searchButton)) expect(router.state.location.pathname).toBe(`/post${tail}`) expect(router.state.location.search).toEqual({ param1: 'value1' }) const searchButton2 = await screen.findByTestId('search2-btn') - fireEvent.click(searchButton2) + await act(() => fireEvent.click(searchButton2)) expect(router.state.location.pathname).toBe(`/post${tail}`) expect(router.state.location.search).toEqual({ param1: 'value2' }) @@ -1737,13 +1737,14 @@ test.each([true, false])( const postsButton = await screen.findByTestId('posts-btn') - fireEvent.click(postsButton) + await act(() => fireEvent.click(postsButton)) expect(await screen.findByTestId('posts-index-heading')).toBeInTheDocument() const post1Button = await screen.findByTestId('first-post-btn') - fireEvent.click(post1Button) + await act(() => fireEvent.click(post1Button)) + expect(await screen.findByTestId('post-heading')).toBeInTheDocument() expect(await screen.findByTestId('detail-heading-1')).toBeInTheDocument() expect(await screen.findByTestId('detail-heading-2')).toBeInTheDocument() @@ -1756,124 +1757,27 @@ test.each([true, false])( const detail1AddBtn = await screen.findByTestId('detail-btn-add-1') - fireEvent.click(detail1AddBtn) + await act(() => fireEvent.click(detail1AddBtn)) expect(router.state.location.pathname).toBe(`/posts/id1/detail${tail}`) expect(router.state.location.search).toEqual({ _test: true }) const detail1RemoveBtn = await screen.findByTestId('detail-btn-remove-1') - fireEvent.click(detail1RemoveBtn) + await act(() => fireEvent.click(detail1RemoveBtn)) expect(router.state.location.pathname).toBe(`/posts/id1/detail${tail}`) expect(router.state.location.search).toEqual({}) const detail2AddBtn = await screen.findByTestId('detail-btn-add-2') - fireEvent.click(detail2AddBtn) + await act(() => fireEvent.click(detail2AddBtn)) expect(router.state.location.pathname).toBe(`/posts/id1/detail${tail}`) expect(router.state.location.search).toEqual({ _test: true }) }, ) -test.each([true, false])( - 'should navigate to current route with search params when using "." in nested route structure from Index Route', - async (trailingSlash: boolean) => { - const tail = trailingSlash ? '/' : '' - - const rootRoute = createRootRoute() - - const IndexComponent = () => { - const navigate = useNavigate() - return ( - <> - - - - - - ) - } - - const indexRoute = createRoute({ - getParentRoute: () => rootRoute, - path: '/', - component: IndexComponent, - validateSearch: z.object({ - param1: z.string().optional(), - }), - }) - - const postRoute = createRoute({ - getParentRoute: () => indexRoute, - path: 'post', - component: () =>
Post
, - }) - - const router = createRouter({ - routeTree: rootRoute.addChildren([indexRoute, postRoute]), - history, - trailingSlash: trailingSlash ? 'always' : 'never', - }) - - render() - - const postButton = await screen.findByTestId('posts-btn') - - fireEvent.click(postButton) - - expect(router.state.location.pathname).toBe(`/post${tail}`) - - const searchButton = await screen.findByTestId('search-btn') - - fireEvent.click(searchButton) - - expect(router.state.location.pathname).toBe(`/post${tail}`) - expect(router.state.location.search).toEqual({ param1: 'value1' }) - - const searchButton2 = await screen.findByTestId('search2-btn') - - fireEvent.click(searchButton2) - - expect(router.state.location.pathname).toBe(`/post${tail}`) - expect(router.state.location.search).toEqual({ param1: 'value2' }) - }, -) - test.each([true, false])( 'should navigate to from route when using "." in nested route structure from Index Route', async (trailingSlash: boolean) => { @@ -1949,20 +1853,20 @@ test.each([true, false])( const postButton = await screen.findByTestId('posts-btn') - fireEvent.click(postButton) + await act(() => fireEvent.click(postButton)) expect(router.state.location.pathname).toBe(`/post${tail}`) const searchButton = await screen.findByTestId('search-btn') - fireEvent.click(searchButton) + await act(() => fireEvent.click(searchButton)) expect(router.state.location.pathname).toBe(`/post${tail}`) expect(router.state.location.search).toEqual({ param1: 'value1' }) const homeBtn = await screen.findByTestId('home-btn') - fireEvent.click(homeBtn) + await act(() => fireEvent.click(homeBtn)) expect(router.state.location.pathname).toBe(`/`) expect(router.state.location.search).toEqual({}) @@ -2402,7 +2306,7 @@ describe.each([{ basepath: '' }, { basepath: '/basepath' }])( return ( <>

Param Route

- From 55ba9afd9de5d8f3682b87233c69d11715ff1bae Mon Sep 17 00:00:00 2001 From: Nico Lynzaad Date: Sun, 17 Aug 2025 14:22:02 +0200 Subject: [PATCH 04/25] code cleanup --- packages/react-router/src/link.tsx | 24 ++++----- packages/router-core/src/router.ts | 80 +++++++++++------------------- 2 files changed, 39 insertions(+), 65 deletions(-) diff --git a/packages/react-router/src/link.tsx b/packages/react-router/src/link.tsx index 69e55d114fe..3b8553c5ad9 100644 --- a/packages/react-router/src/link.tsx +++ b/packages/react-router/src/link.tsx @@ -245,19 +245,6 @@ export function useLinkProps< } }, [disabled, doPreload, preload]) - const navigate = React.useCallback(() => { - router.navigate({ - ...options, - from: getFrom(), - replace, - resetScroll, - hashScrollIntoView, - startTransition, - viewTransition, - ignoreBlocker, - }) - }, [router, options, getFrom, replace, resetScroll, hashScrollIntoView, startTransition, viewTransition, ignoreBlocker]) - // The click handler const handleClick = (e: React.MouseEvent) => { if ( @@ -280,7 +267,16 @@ 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 - navigate() + router.navigate({ + ...options, + from: getFrom(), + replace, + resetScroll, + hashScrollIntoView, + startTransition, + viewTransition, + ignoreBlocker, + }) } } diff --git a/packages/router-core/src/router.ts b/packages/router-core/src/router.ts index 2c602f00e5d..030ebd910bf 100644 --- a/packages/router-core/src/router.ts +++ b/packages/router-core/src/router.ts @@ -1414,62 +1414,39 @@ export class RouterCore< _buildLocation: true, }) - const lastMatch = last(allCurrentLocationMatches)! - - // First let's find the starting pathname - // By default, start with the current location - let fromPath = this.resolvePathWithBase(lastMatch.fullPath, '.') - const destFromPath = dest.from && this.resolvePathWithBase(dest.from, '.') - - const toPath = destFromPath - ? this.resolvePathWithBase(destFromPath, `${dest.to ?? "."}`) - : this.resolvePathWithBase(fromPath, `${dest.to ?? "."}`) - - const routeIsChanging = - !!dest.to && - ( - comparePaths(destFromPath ?? fromPath, fromPath) ? - ( - !comparePaths(toPath, fromPath) - ) : - ( - !comparePaths(toPath, destFromPath!) || - !comparePaths(toPath, fromPath) - ) - ) - - // If the route is changing we need to find the relative fromPath - if (dest.unsafeRelative === 'path') { - fromPath = currentLocation.pathname - } else if (routeIsChanging && dest.from) { - fromPath = dest.from - - // do this check only on navigations during test or development - if (process.env.NODE_ENV !== 'production' && dest._isNavigate) { - const allFromMatches = this.getMatchedRoutes( - dest.from, - undefined, - ).matchedRoutes - - const matchedFrom = [...allCurrentLocationMatches] - .reverse() - .find((d) => { - return comparePaths(d.fullPath, fromPath) - }) - - const matchedCurrent = [...allFromMatches].reverse().find((d) => { - return comparePaths(d.fullPath, currentLocation.pathname) + // check that from path exists in the current route tree + // do this check only on navigations during test or development + if (dest.from && process.env.NODE_ENV !== 'production' && dest._isNavigate) { + const allFromMatches = this.getMatchedRoutes( + dest.from, + undefined, + ).matchedRoutes + + const matchedFrom = [...allCurrentLocationMatches] + .reverse() + .find((d) => { + return comparePaths(d.fullPath, dest.from!) }) - // for from to be invalid it shouldn't just be unmatched to currentLocation - // but the currentLocation should also be unmatched to from - if (!matchedFrom && !matchedCurrent) { - console.warn(`Could not find match for from: ${fromPath}`) - } + const matchedCurrent = [...allFromMatches].reverse().find((d) => { + return comparePaths(d.fullPath, currentLocation.pathname) + }) + + // for from to be invalid it shouldn't just be unmatched to currentLocation + // but the currentLocation should also be unmatched to from + if (!matchedFrom && !matchedCurrent) { + console.warn(`Could not find match for from: ${dest.from}`) } } - fromPath = this.resolvePathWithBase(fromPath, '.') + // Now let's find the starting pathname + // This should default to the current location if no from is provided + const lastMatch = last(allCurrentLocationMatches)! + + const defaultedFromPath = dest.unsafeRelative === 'path' ? currentLocation.pathname : dest.from ?? lastMatch.fullPath + + // ensure this includes the basePath if set + const fromPath = this.resolvePathWithBase(defaultedFromPath, '.') // From search should always use the current location const fromSearch = lastMatch.search @@ -1477,6 +1454,7 @@ export class RouterCore< const fromParams = { ...lastMatch.params } // Resolve the next to + // ensure this includes the basePath if set const nextTo = dest.to ? this.resolvePathWithBase(fromPath, `${dest.to}`) : this.resolvePathWithBase(fromPath, '.') From ba524f121c807867a4e52e23e9162c1afebb97db Mon Sep 17 00:00:00 2001 From: Nico Lynzaad Date: Sun, 17 Aug 2025 14:27:36 +0200 Subject: [PATCH 05/25] test cleanup --- packages/react-router/tests/link.test.tsx | 10 +- .../react-router/tests/useNavigate.test.tsx | 1474 +++++++++-------- 2 files changed, 744 insertions(+), 740 deletions(-) diff --git a/packages/react-router/tests/link.test.tsx b/packages/react-router/tests/link.test.tsx index 67ab8a8eb6d..5e4a900a322 100644 --- a/packages/react-router/tests/link.test.tsx +++ b/packages/react-router/tests/link.test.tsx @@ -5411,7 +5411,7 @@ describe.each([{ basepath: '' }, { basepath: '/basepath' }])( describe('relative links to current route', () => { test.each([true, false])( - 'should navigate to current route when using "." in nested route structure from Index Route', + 'should navigate to current route when using "." in nested route structure from Index Route with trailingSlash: %s', async (trailingSlash: boolean) => { const tail = trailingSlash ? '/' : '' @@ -5493,7 +5493,7 @@ describe('relative links to current route', () => { ) test.each([true, false])( - 'should navigate to current route with search params when using "." in nested route structure from Index Route', + 'should navigate to current route with search params when using "." in nested route structure from Index Route with trailingSlash: %s', async (trailingSlash: boolean) => { const tail = trailingSlash ? '/' : '' @@ -5573,7 +5573,7 @@ describe('relative links to current route', () => { ) test.each([true, false])( - 'should navigate to current route with changing path params when using "." in nested route structure', + 'should navigate to current route with changing path params when using "." in nested route structure with trailingSlash: %s', async (trailingSlash) => { const tail = trailingSlash ? '/' : '' const rootRoute = createRootRoute() @@ -5693,7 +5693,7 @@ describe('relative links to current route', () => { describe('relative links to from route', () => { test.each([true, false])( - 'should navigate to from route when using "." in nested route structure from Index Route', + 'should navigate to from route when using "." in nested route structure from Index Route with trailingSlash: %s', async (trailingSlash: boolean) => { const tail = trailingSlash ? '/' : '' @@ -5773,7 +5773,7 @@ describe('relative links to from route', () => { ) test.each([true, false])( - 'should navigate to from route with path params when using "." in nested route structure', + 'should navigate to from route with path params when using "." in nested route structure with trailingSlash: %s', async (trailingSlash) => { const tail = trailingSlash ? '/' : '' const rootRoute = createRootRoute() diff --git a/packages/react-router/tests/useNavigate.test.tsx b/packages/react-router/tests/useNavigate.test.tsx index 275f2a8e287..51fe8d28ede 100644 --- a/packages/react-router/tests/useNavigate.test.tsx +++ b/packages/react-router/tests/useNavigate.test.tsx @@ -1366,895 +1366,899 @@ test(' navigates only once in ', async () => { expect(navigateSpy.mock.calls.length).toBe(1) }) -test.each([true, false])( - 'should navigate to current route with search params when using "." in nested route structure from Index Route', - async (trailingSlash: boolean) => { - const tail = trailingSlash ? '/' : '' +describe('relative navigate to current route', () => { + test.each([true, false])( + 'should navigate to current route with search params when using "." in nested route structure from Index Route with trailingSlash: %s', + async (trailingSlash: boolean) => { + const tail = trailingSlash ? '/' : '' - const rootRoute = createRootRoute() + const rootRoute = createRootRoute() - const IndexComponent = () => { - const navigate = useNavigate() - return ( - <> - - - - - - ) - } + const IndexComponent = () => { + const navigate = useNavigate() + return ( + <> + + + + + + ) + } - const indexRoute = createRoute({ - getParentRoute: () => rootRoute, - path: '/', - component: IndexComponent, - validateSearch: z.object({ - param1: z.string().optional(), - }), - }) + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: IndexComponent, + validateSearch: z.object({ + param1: z.string().optional(), + }), + }) - const postRoute = createRoute({ - getParentRoute: () => indexRoute, - path: 'post', - component: () =>
Post
, - }) + const postRoute = createRoute({ + getParentRoute: () => indexRoute, + path: 'post', + component: () =>
Post
, + }) - const router = createRouter({ - routeTree: rootRoute.addChildren([indexRoute, postRoute]), - history, - trailingSlash: trailingSlash ? 'always' : 'never', - }) + const router = createRouter({ + routeTree: rootRoute.addChildren([indexRoute, postRoute]), + history, + trailingSlash: trailingSlash ? 'always' : 'never', + }) - render() + render() - const postButton = await screen.findByTestId('posts-btn') + const postButton = await screen.findByTestId('posts-btn') - await act(() => fireEvent.click(postButton)) + await act(() => fireEvent.click(postButton)) - expect(router.state.location.pathname).toBe(`/post${tail}`) + expect(router.state.location.pathname).toBe(`/post${tail}`) - const searchButton = await screen.findByTestId('search-btn') + const searchButton = await screen.findByTestId('search-btn') - await act(() => fireEvent.click(searchButton)) + await act(() => fireEvent.click(searchButton)) - expect(router.state.location.pathname).toBe(`/post${tail}`) - expect(router.state.location.search).toEqual({ param1: 'value1' }) + expect(router.state.location.pathname).toBe(`/post${tail}`) + expect(router.state.location.search).toEqual({ param1: 'value1' }) - const searchButton2 = await screen.findByTestId('search2-btn') + const searchButton2 = await screen.findByTestId('search2-btn') - await act(() => fireEvent.click(searchButton2)) + await act(() => fireEvent.click(searchButton2)) - expect(router.state.location.pathname).toBe(`/post${tail}`) - expect(router.state.location.search).toEqual({ param1: 'value2' }) - }, -) + expect(router.state.location.pathname).toBe(`/post${tail}`) + expect(router.state.location.search).toEqual({ param1: 'value2' }) + }, + ) -test.each([true, false])( - 'should navigate to current route with changing path params when using "." in nested route structure', - async (trailingSlash) => { - const tail = trailingSlash ? '/' : '' - const rootRoute = createRootRoute() + test.each([true, false])( + 'should navigate to current route with changing path params when using "." in nested route structure with trailingSlash: %s', + async (trailingSlash) => { + const tail = trailingSlash ? '/' : '' + const rootRoute = createRootRoute() - const IndexComponent = () => { - const navigate = useNavigate() - return ( - <> -

Index

- - - ) - } + const IndexComponent = () => { + const navigate = useNavigate() + return ( + <> +

Index

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

Layout

+ + + ) + }, + }) + + const PostsComponent = () => { + const navigate = useNavigate() - const layoutRoute = createRoute({ - getParentRoute: () => rootRoute, - id: '_layout', - component: () => { return ( <> -

Layout

+

Posts

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

Posts

- - - - - ) - } + const postsRoute = createRoute({ + getParentRoute: () => layoutRoute, + path: 'posts', + component: PostsComponent, + }) - const postsRoute = createRoute({ - getParentRoute: () => layoutRoute, - path: 'posts', - component: PostsComponent, - }) + const PostComponent = () => { + const params = useParams({ strict: false }) + return ( + <> + + Params: {params.postId} + + + ) + } - const PostComponent = () => { - const params = useParams({ strict: false }) - return ( - <> - - Params: {params.postId} - - - ) - } + const postRoute = createRoute({ + getParentRoute: () => postsRoute, + path: '$postId', + component: PostComponent, + }) - const postRoute = createRoute({ - getParentRoute: () => postsRoute, - path: '$postId', - component: PostComponent, - }) + const router = createRouter({ + routeTree: rootRoute.addChildren([ + indexRoute, + layoutRoute.addChildren([postsRoute.addChildren([postRoute])]), + ]), + trailingSlash: trailingSlash ? 'always' : 'never', + }) - const router = createRouter({ - routeTree: rootRoute.addChildren([ - indexRoute, - layoutRoute.addChildren([postsRoute.addChildren([postRoute])]), - ]), - trailingSlash: trailingSlash ? 'always' : 'never', - }) + render() - render() + const postsButton = await screen.findByTestId('posts-btn') - const postsButton = await screen.findByTestId('posts-btn') + fireEvent.click(postsButton) - fireEvent.click(postsButton) + expect(await screen.findByTestId('posts-index-heading')).toBeInTheDocument() + expect(window.location.pathname).toEqual(`/posts${tail}`) - expect(await screen.findByTestId('posts-index-heading')).toBeInTheDocument() - expect(window.location.pathname).toEqual(`/posts${tail}`) + const firstPostButton = await screen.findByTestId('first-post-btn') - const firstPostButton = await screen.findByTestId('first-post-btn') + fireEvent.click(firstPostButton) - fireEvent.click(firstPostButton) + expect(await screen.findByTestId('post-id1')).toBeInTheDocument() + expect(window.location.pathname).toEqual(`/posts/id1${tail}`) - expect(await screen.findByTestId('post-id1')).toBeInTheDocument() - expect(window.location.pathname).toEqual(`/posts/id1${tail}`) + const secondPostButton = await screen.findByTestId('second-post-btn') - const secondPostButton = await screen.findByTestId('second-post-btn') + fireEvent.click(secondPostButton) - fireEvent.click(secondPostButton) + expect(await screen.findByTestId('post-id2')).toBeInTheDocument() + expect(window.location.pathname).toEqual(`/posts/id2${tail}`) + }, + ) - expect(await screen.findByTestId('post-id2')).toBeInTheDocument() - expect(window.location.pathname).toEqual(`/posts/id2${tail}`) - }, -) + test.each([true, false])( + 'should navigate to current route with search params when using "." in nested route structure from non-Index Route with trailingSlash: %s', + async (trailingSlash) => { + const tail = trailingSlash ? '/' : '' + const rootRoute = createRootRoute() -test.each([true, false])( - 'should navigate to current route with search params when using "." in nested route structure from non-Index Route', - async (trailingSlash) => { - const tail = trailingSlash ? '/' : '' - const rootRoute = createRootRoute() + const IndexComponent = () => { + const navigate = useNavigate() + return ( + <> +

Index

+ + + ) + } - const IndexComponent = () => { - const navigate = useNavigate() - return ( - <> -

Index

- - - ) - } + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: IndexComponent, + }) - const indexRoute = createRoute({ - getParentRoute: () => rootRoute, - path: '/', - component: IndexComponent, - }) + const PostsComponent = () => { + const navigate = postsRoute.useNavigate() + return ( + <> +

Posts

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

Posts

- - - - ) - } + const postsRoute = createRoute({ + getParentRoute: () => rootRoute, + path: 'posts', + component: PostsComponent, + }) - const postsRoute = createRoute({ - getParentRoute: () => rootRoute, - path: 'posts', - component: PostsComponent, - }) + const useModal = (name: string) => { + const currentOpen = postRoute.useSearch({ + select: (search) => search[`_${name}`], + }) - const useModal = (name: string) => { - const currentOpen = postRoute.useSearch({ - select: (search) => search[`_${name}`], - }) + const navigate = useNavigate() - const navigate = useNavigate() - - const setModal = React.useCallback( - (open: boolean) => { - navigate({ - to: '.', - search: (prev: {}) => ({ - ...prev, - [`_${name}`]: open ? true : undefined, - }), - resetScroll: false, - }) - }, - [name, navigate], - ) + const setModal = React.useCallback( + (open: boolean) => { + navigate({ + to: '.', + search: (prev: {}) => ({ + ...prev, + [`_${name}`]: open ? true : undefined, + }), + resetScroll: false, + }) + }, + [name, navigate], + ) - return [currentOpen, setModal] as const - } + return [currentOpen, setModal] as const + } - function DetailComponent(props: { id: string }) { - const params = useParams({ strict: false }) - const [currentTest, setTest] = useModal('test') + function DetailComponent(props: { id: string }) { + const params = useParams({ strict: false }) + const [currentTest, setTest] = useModal('test') - return ( - <> -
- Post Path "/{params.postId}/detail-{props.id}"! -
- {currentTest ? ( - - ) : ( - - )} - - ) - } + return ( + <> +
+ Post Path "/{params.postId}/detail-{props.id}"! +
+ {currentTest ? ( + + ) : ( + + )} + + ) + } - const PostComponent = () => { - const params = useParams({ strict: false }) + const PostComponent = () => { + const params = useParams({ strict: false }) - return ( -
-
Post "{params.postId}"!
- - -
- ) - } + return ( +
+
Post "{params.postId}"!
+ + +
+ ) + } + + const postRoute = createRoute({ + getParentRoute: () => postsRoute, + path: '$postId', + component: PostComponent, + validateSearch: z.object({ + _test: z.boolean().optional(), + }), + }) - const postRoute = createRoute({ - getParentRoute: () => postsRoute, - path: '$postId', - component: PostComponent, - validateSearch: z.object({ - _test: z.boolean().optional(), - }), - }) + const detailRoute = createRoute({ + getParentRoute: () => postRoute, + path: 'detail', + component: () => , + }) - const detailRoute = createRoute({ - getParentRoute: () => postRoute, - path: 'detail', - component: () => , - }) + const router = createRouter({ + routeTree: rootRoute.addChildren([ + indexRoute, + postsRoute.addChildren([postRoute.addChildren([detailRoute])]), + ]), + trailingSlash: trailingSlash ? 'always' : 'never', + }) - const router = createRouter({ - routeTree: rootRoute.addChildren([ - indexRoute, - postsRoute.addChildren([postRoute.addChildren([detailRoute])]), - ]), - trailingSlash: trailingSlash ? 'always' : 'never', - }) + render() - render() + const postsButton = await screen.findByTestId('posts-btn') - const postsButton = await screen.findByTestId('posts-btn') + await act(() => fireEvent.click(postsButton)) - await act(() => fireEvent.click(postsButton)) + expect(await screen.findByTestId('posts-index-heading')).toBeInTheDocument() - expect(await screen.findByTestId('posts-index-heading')).toBeInTheDocument() + const post1Button = await screen.findByTestId('first-post-btn') - const post1Button = await screen.findByTestId('first-post-btn') + await act(() => fireEvent.click(post1Button)) - await act(() => fireEvent.click(post1Button)) + expect(await screen.findByTestId('post-heading')).toBeInTheDocument() + expect(await screen.findByTestId('detail-heading-1')).toBeInTheDocument() + expect(await screen.findByTestId('detail-heading-2')).toBeInTheDocument() + expect(await screen.findByTestId('detail-heading-1')).toHaveTextContent( + 'Post Path "/id1/detail-1', + ) + expect(await screen.findByTestId('detail-heading-2')).toHaveTextContent( + 'Post Path "/id1/detail-2', + ) - expect(await screen.findByTestId('post-heading')).toBeInTheDocument() - expect(await screen.findByTestId('detail-heading-1')).toBeInTheDocument() - expect(await screen.findByTestId('detail-heading-2')).toBeInTheDocument() - expect(await screen.findByTestId('detail-heading-1')).toHaveTextContent( - 'Post Path "/id1/detail-1', - ) - expect(await screen.findByTestId('detail-heading-2')).toHaveTextContent( - 'Post Path "/id1/detail-2', - ) + const detail1AddBtn = await screen.findByTestId('detail-btn-add-1') - const detail1AddBtn = await screen.findByTestId('detail-btn-add-1') + await act(() => fireEvent.click(detail1AddBtn)) - await act(() => fireEvent.click(detail1AddBtn)) + expect(router.state.location.pathname).toBe(`/posts/id1/detail${tail}`) + expect(router.state.location.search).toEqual({ _test: true }) - expect(router.state.location.pathname).toBe(`/posts/id1/detail${tail}`) - expect(router.state.location.search).toEqual({ _test: true }) + const detail1RemoveBtn = await screen.findByTestId('detail-btn-remove-1') - const detail1RemoveBtn = await screen.findByTestId('detail-btn-remove-1') + await act(() => fireEvent.click(detail1RemoveBtn)) - await act(() => fireEvent.click(detail1RemoveBtn)) + expect(router.state.location.pathname).toBe(`/posts/id1/detail${tail}`) + expect(router.state.location.search).toEqual({}) - expect(router.state.location.pathname).toBe(`/posts/id1/detail${tail}`) - expect(router.state.location.search).toEqual({}) + const detail2AddBtn = await screen.findByTestId('detail-btn-add-2') - const detail2AddBtn = await screen.findByTestId('detail-btn-add-2') + await act(() => fireEvent.click(detail2AddBtn)) - await act(() => fireEvent.click(detail2AddBtn)) + expect(router.state.location.pathname).toBe(`/posts/id1/detail${tail}`) + expect(router.state.location.search).toEqual({ _test: true }) + }, + ) +}) - expect(router.state.location.pathname).toBe(`/posts/id1/detail${tail}`) - expect(router.state.location.search).toEqual({ _test: true }) - }, -) +describe('relative navigate to from route', () => { + test.each([true, false])( + 'should navigate to from route when using "." in nested route structure from Index Route with trailingSlash: %s', + async (trailingSlash: boolean) => { + const tail = trailingSlash ? '/' : '' -test.each([true, false])( - 'should navigate to from route when using "." in nested route structure from Index Route', - async (trailingSlash: boolean) => { - const tail = trailingSlash ? '/' : '' + const rootRoute = createRootRoute() - const rootRoute = createRootRoute() + const IndexComponent = () => { + const navigate = useNavigate() + return ( + <> + + + + + + ) + } - const IndexComponent = () => { - const navigate = useNavigate() - return ( - <> - - - - - - ) - } + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: IndexComponent, + validateSearch: z.object({ + param1: z.string().optional(), + }), + }) - const indexRoute = createRoute({ - getParentRoute: () => rootRoute, - path: '/', - component: IndexComponent, - validateSearch: z.object({ - param1: z.string().optional(), - }), - }) + const postRoute = createRoute({ + getParentRoute: () => indexRoute, + path: 'post', + component: () =>
Post
, + }) - const postRoute = createRoute({ - getParentRoute: () => indexRoute, - path: 'post', - component: () =>
Post
, - }) + const router = createRouter({ + routeTree: rootRoute.addChildren([indexRoute, postRoute]), + history, + trailingSlash: trailingSlash ? 'always' : 'never', + }) - const router = createRouter({ - routeTree: rootRoute.addChildren([indexRoute, postRoute]), - history, - trailingSlash: trailingSlash ? 'always' : 'never', - }) + render() - render() + const postButton = await screen.findByTestId('posts-btn') - const postButton = await screen.findByTestId('posts-btn') + await act(() => fireEvent.click(postButton)) - await act(() => fireEvent.click(postButton)) + expect(router.state.location.pathname).toBe(`/post${tail}`) - expect(router.state.location.pathname).toBe(`/post${tail}`) + const searchButton = await screen.findByTestId('search-btn') - const searchButton = await screen.findByTestId('search-btn') + await act(() => fireEvent.click(searchButton)) - await act(() => fireEvent.click(searchButton)) + expect(router.state.location.pathname).toBe(`/post${tail}`) + expect(router.state.location.search).toEqual({ param1: 'value1' }) - expect(router.state.location.pathname).toBe(`/post${tail}`) - expect(router.state.location.search).toEqual({ param1: 'value1' }) + const homeBtn = await screen.findByTestId('home-btn') - const homeBtn = await screen.findByTestId('home-btn') + await act(() => fireEvent.click(homeBtn)) - await act(() => fireEvent.click(homeBtn)) + expect(router.state.location.pathname).toBe(`/`) + expect(router.state.location.search).toEqual({}) + }, + ) - expect(router.state.location.pathname).toBe(`/`) - expect(router.state.location.search).toEqual({}) - }, -) + test.each([true, false])( + 'should navigate to from route with path params when using "." in nested route structure with trailingSlash: %s', + async (trailingSlash) => { + const tail = trailingSlash ? '/' : '' + const rootRoute = createRootRoute() -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 IndexComponent = () => { - const navigate = useNavigate() - return ( - <> -

Index

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

Layout

+ + + ) + }, + }) - const layoutRoute = createRoute({ - getParentRoute: () => rootRoute, - id: '_layout', - component: () => { + const PostsComponent = () => { + const navigate = postsRoute.useNavigate() return ( <> -

Layout

+

Posts

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

Posts

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

Post Detail

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

Post Detail

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

Post Info

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

Post Info

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

Post Notes

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

Post Notes

- - ) - } + const postsRoute = createRoute({ + getParentRoute: () => layoutRoute, + path: 'posts', + component: PostsComponent, + }) - const postsRoute = createRoute({ - getParentRoute: () => layoutRoute, - path: 'posts', - component: PostsComponent, - }) + const postDetailRoute = createRoute({ + getParentRoute: () => postsRoute, + path: '$postId', + component: PostDetailComponent, + }) - const postDetailRoute = createRoute({ - getParentRoute: () => postsRoute, - path: '$postId', - component: PostDetailComponent, - }) + const postInfoRoute = createRoute({ + getParentRoute: () => postDetailRoute, + path: 'info', + component: PostInfoComponent, + }) - const postInfoRoute = createRoute({ - getParentRoute: () => postDetailRoute, - path: 'info', - component: PostInfoComponent, - }) + const postNotesRoute = createRoute({ + getParentRoute: () => postDetailRoute, + path: 'notes', + component: PostNotesComponent, + }) - 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', + }) - const router = createRouter({ - routeTree: rootRoute.addChildren([ - indexRoute, - layoutRoute.addChildren([postsRoute.addChildren([postDetailRoute.addChildren([postInfoRoute, postNotesRoute])])]), - ]), - trailingSlash: trailingSlash ? 'always' : 'never', - }) + render() - render() + const postsButton = await screen.findByTestId('posts-btn') - const postsButton = await screen.findByTestId('posts-btn') + fireEvent.click(postsButton) - fireEvent.click(postsButton) + expect(await screen.findByTestId('posts-index-heading')).toBeInTheDocument() + expect(window.location.pathname).toEqual(`/posts${tail}`) - expect(await screen.findByTestId('posts-index-heading')).toBeInTheDocument() - expect(window.location.pathname).toEqual(`/posts${tail}`) + const firstPostButton = await screen.findByTestId('first-post-btn') - const firstPostButton = await screen.findByTestId('first-post-btn') + fireEvent.click(firstPostButton) - fireEvent.click(firstPostButton) + expect(await screen.findByTestId('post-detail-index-heading')).toBeInTheDocument() + expect(window.location.pathname).toEqual(`/posts/1${tail}`) - 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') - const postInfoButton = await screen.findByTestId('post-info-btn') + fireEvent.click(postInfoButton) - fireEvent.click(postInfoButton) + expect(await screen.findByTestId('post-info-heading')).toBeInTheDocument() + expect(window.location.pathname).toEqual(`/posts/1/info${tail}`) - 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) - 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}`) - 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') - const postNotesButton = await screen.findByTestId('post-notes-btn') + fireEvent.click(postNotesButton) - fireEvent.click(postNotesButton) + expect(await screen.findByTestId('post-notes-heading')).toBeInTheDocument() + expect(window.location.pathname).toEqual(`/posts/1/notes${tail}`) - 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') - const toPostsIndexButton = await screen.findByTestId('to-posts-index-btn') + fireEvent.click(toPostsIndexButton) - 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}`) - 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') - const secondPostButton = await screen.findByTestId('second-post-btn') + fireEvent.click(secondPostButton) - fireEvent.click(secondPostButton) + expect(await screen.findByTestId('post-detail-index-heading')).toBeInTheDocument() + expect(window.location.pathname).toEqual(`/posts/2${tail}`) + }, + ) - expect(await screen.findByTestId('post-detail-index-heading')).toBeInTheDocument() - expect(window.location.pathname).toEqual(`/posts/2${tail}`) - }, -) + describe('when on /posts/$postId and navigating to ../ with default `from` /posts', () => { + async function runTest(navigateVia: 'Route' | 'RouteApi') { + const rootRoute = createRootRoute() -describe('when on /posts/$postId and navigating to ../ with default `from` /posts', () => { - async function runTest(navigateVia: 'Route' | 'RouteApi') { - const rootRoute = createRootRoute() + const IndexComponent = () => { + const navigate = useNavigate() + return ( + <> +

Index

+ + + + ) + } - const IndexComponent = () => { - const navigate = useNavigate() - return ( - <> -

Index

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

Layout

+ + + ) + }, + }) - const layoutRoute = createRoute({ - getParentRoute: () => rootRoute, - id: '_layout', - component: () => { + const PostsComponent = () => { + const routeNavigate = postsRoute.useNavigate() + const routeApiNavigate = getRouteApi('/_layout/posts').useNavigate() return ( <> -

Layout

+

Posts

+ ) - }, - }) + } - const PostsComponent = () => { - const routeNavigate = postsRoute.useNavigate() - const routeApiNavigate = getRouteApi('/_layout/posts').useNavigate() - return ( - <> -

Posts

- - - - ) - } - - const postsRoute = createRoute({ - getParentRoute: () => layoutRoute, - path: 'posts', - component: PostsComponent, - }) + const postsRoute = createRoute({ + getParentRoute: () => layoutRoute, + path: 'posts', + component: PostsComponent, + }) - const PostComponent = () => { - const params = useParams({ strict: false }) - return ( - <> - Params: {params.postId} - - - ) - } + const PostComponent = () => { + const params = useParams({ strict: false }) + return ( + <> + Params: {params.postId} + + + ) + } - const postRoute = createRoute({ - getParentRoute: () => postsRoute, - path: '$postId', - component: PostComponent, - }) + const postRoute = createRoute({ + getParentRoute: () => postsRoute, + path: '$postId', + component: PostComponent, + }) - const PostIndexComponent = () => { - return ( - <> -

Post Index

- - ) - } + const PostIndexComponent = () => { + return ( + <> +

Post Index

+ + ) + } - const postIndexRoute = createRoute({ - getParentRoute: () => postRoute, - path: '/', - component: PostIndexComponent, - }) + const postIndexRoute = createRoute({ + getParentRoute: () => postRoute, + path: '/', + component: PostIndexComponent, + }) - const DetailsComponent = () => { - return ( - <> -

Details!

- - ) - } + const DetailsComponent = () => { + return ( + <> +

Details!

+ + ) + } - const detailsRoute = createRoute({ - getParentRoute: () => postRoute, - path: 'details', - component: DetailsComponent, - }) + const detailsRoute = createRoute({ + getParentRoute: () => postRoute, + path: 'details', + component: DetailsComponent, + }) - const router = createRouter({ - routeTree: rootRoute.addChildren([ - indexRoute, - layoutRoute.addChildren([ - postsRoute.addChildren([ - postRoute.addChildren([postIndexRoute, detailsRoute]), + const router = createRouter({ + routeTree: rootRoute.addChildren([ + indexRoute, + layoutRoute.addChildren([ + postsRoute.addChildren([ + postRoute.addChildren([postIndexRoute, detailsRoute]), + ]), ]), ]), - ]), - }) + }) - render() + render() - const postsButton = await screen.findByTestId('index-to-first-post-btn') + const postsButton = await screen.findByTestId('index-to-first-post-btn') - fireEvent.click(postsButton) + fireEvent.click(postsButton) - expect(await screen.findByTestId('details-heading')).toBeInTheDocument() + expect(await screen.findByTestId('details-heading')).toBeInTheDocument() - expect(window.location.pathname).toEqual('/posts/id1/details') + expect(window.location.pathname).toEqual('/posts/id1/details') - const homeButton = await screen.findByTestId('btn-to-home') + const homeButton = await screen.findByTestId('btn-to-home') - fireEvent.click(homeButton) + fireEvent.click(homeButton) - expect(await screen.findByTestId('index-heading')).toBeInTheDocument() - expect(window.location.pathname).toEqual('/') - } + expect(await screen.findByTestId('index-heading')).toBeInTheDocument() + expect(window.location.pathname).toEqual('/') + } - test('Route', () => runTest('Route')) - test('RouteApi', () => runTest('RouteApi')) + test('Route', () => runTest('Route')) + test('RouteApi', () => runTest('RouteApi')) + }) }) describe.each([{ basepath: '' }, { basepath: '/basepath' }])( From 745107c9bc03342c717bdd9fce16c2dc60ee73fa Mon Sep 17 00:00:00 2001 From: Nico Lynzaad Date: Sun, 17 Aug 2025 15:23:55 +0200 Subject: [PATCH 06/25] replicate changes to Solid --- packages/solid-router/src/link.tsx | 27 +- packages/solid-router/src/useNavigate.tsx | 11 +- packages/solid-router/tests/link.test.tsx | 612 +++++++++- .../solid-router/tests/useNavigate.test.tsx | 1039 +++++++++++++++-- 4 files changed, 1555 insertions(+), 134 deletions(-) diff --git a/packages/solid-router/src/link.tsx b/packages/solid-router/src/link.tsx index 008483ac0d8..807a05579de 100644 --- a/packages/solid-router/src/link.tsx +++ b/packages/solid-router/src/link.tsx @@ -135,15 +135,30 @@ export function useLinkProps< // when `from` is not supplied, use the route of the current match as the `from` location // so relative routing works as expected - const from = useMatch({ + // const from = useMatch({ + // strict: false, + // select: (match) => options.from ?? match.fullPath, + // }) + + const matchIndex = useMatch({ strict: false, - select: (match) => options.from ?? match.fullPath, + select: (match) => match.index, }) - const _options = () => ({ - ...options, - from: from(), - }) + const _options = () => { + const currentRouteMatches= router.matchRoutes(router.latestLocation, { + _buildLocation: false, + }) + + const from = options.from ?? + currentRouteMatches.slice(-1)[0]?.fullPath ?? + router.state.matches[matchIndex()]!.fullPath + + return ({ + ...options, + from + }) + } const next = Solid.createMemo(() => { currentSearch() diff --git a/packages/solid-router/src/useNavigate.tsx b/packages/solid-router/src/useNavigate.tsx index 96d1207533b..94c0374e223 100644 --- a/packages/solid-router/src/useNavigate.tsx +++ b/packages/solid-router/src/useNavigate.tsx @@ -15,7 +15,7 @@ export function useNavigate< >(_defaultOpts?: { from?: FromPathOption }): UseNavigateResult { - const { navigate, state } = useRouter() + const router = useRouter() const matchIndex = useMatch({ strict: false, @@ -23,12 +23,17 @@ export function useNavigate< }) return ((options: NavigateOptions) => { - return navigate({ + const currentRouteMatches= router.matchRoutes(router.latestLocation, { + _buildLocation: false, + }) + + return router.navigate({ ...options, from: options.from ?? _defaultOpts?.from ?? - state.matches[matchIndex()]!.fullPath, + currentRouteMatches.slice(-1)[0]?.fullPath ?? + router.state.matches[matchIndex()]!.fullPath }) }) as UseNavigateResult } diff --git a/packages/solid-router/tests/link.test.tsx b/packages/solid-router/tests/link.test.tsx index f1243d9f7fb..4afbacf1218 100644 --- a/packages/solid-router/tests/link.test.tsx +++ b/packages/solid-router/tests/link.test.tsx @@ -4591,6 +4591,612 @@ describe('search middleware', () => { }) }) +describe('relative links to current route', () => { + test.each([true, false])( + 'should navigate to current route when using "." in nested route structure from Index Route with trailingSlash: %s', + async (trailingSlash: boolean) => { + const tail = trailingSlash ? '/' : '' + + const rootRoute = createRootRoute() + + const IndexComponent = () => { + return ( + <> + + Post + + + Search + + + Search2 + + + + ) + } + + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: IndexComponent, + validateSearch: z.object({ + param1: z.string().optional(), + }), + }) + + const postRoute = createRoute({ + getParentRoute: () => indexRoute, + path: 'post', + component: () => ( + <> +
Post
+ + ), + }) + + const router = createRouter({ + routeTree: rootRoute.addChildren([indexRoute, postRoute]), + history, + trailingSlash: trailingSlash ? 'always' : 'never', + }) + + render(() => ) + + const postButton = await screen.findByTestId('posts-link') + const searchButton = await screen.findByTestId('search-link') + const searchButton2 = await screen.findByTestId('search2-link') + + fireEvent.click(postButton) + + await waitFor(() => { + expect(window.location.pathname).toBe(`/post${tail}`) + }) + + fireEvent.click(searchButton) + + await waitFor(() => { + expect(router.state.location.pathname).toBe(`/post${tail}`) + expect(router.state.location.search).toEqual({ param1: 'value1' }) + }) + + fireEvent.click(searchButton2) + + await waitFor(() => { + expect(router.state.location.pathname).toBe(`/post${tail}`) + expect(router.state.location.search).toEqual({ param1: 'value2' }) + }) + }, + ) + + test.each([true, false])( + 'should navigate to current route with search params when using "." in nested route structure from Index Route with trailingSlash: %s', + async (trailingSlash: boolean) => { + const tail = trailingSlash ? '/' : '' + + const rootRoute = createRootRoute() + + const IndexComponent = () => { + return ( + <> + + Post + + + Search + + + Search2 + + + + ) + } + + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: IndexComponent, + validateSearch: z.object({ + param1: z.string().optional(), + }), + }) + + const postRoute = createRoute({ + getParentRoute: () => indexRoute, + path: 'post', + component: () =>
Post
, + }) + + const router = createRouter({ + routeTree: rootRoute.addChildren([indexRoute, postRoute]), + history, + trailingSlash: trailingSlash ? 'always' : 'never', + }) + + render(() => ) + + const postButton = await screen.findByTestId('posts-link') + + fireEvent.click(postButton) + + await waitFor(() => { + expect(window.location.pathname).toBe(`/post${tail}`) + }) + + const searchButton = await screen.findByTestId('search-link') + + fireEvent.click(searchButton) + + await waitFor(() => { + expect(router.state.location.pathname).toBe(`/post${tail}`) + expect(router.state.location.search).toEqual({ param1: 'value1' }) + }) + + const searchButton2 = await screen.findByTestId('search2-link') + + fireEvent.click(searchButton2) + + await waitFor(() => { + expect(router.state.location.pathname).toBe(`/post${tail}`) + expect(router.state.location.search).toEqual({ param1: 'value2' }) + }) + }, + ) + + test.each([true, false])( + 'should navigate to current route with changing path params when using "." in nested route structure with trailingSlash: %s', + async (trailingSlash) => { + const tail = trailingSlash ? '/' : '' + const rootRoute = createRootRoute() + + const IndexComponent = () => { + return ( + <> +

Index

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

Layout

+ + + ) + }, + }) + + const PostsComponent = () => { + return ( + <> +

Posts

+ + To first post + + + To second post + + + + ) + } + + const postsRoute = createRoute({ + getParentRoute: () => layoutRoute, + path: 'posts', + component: PostsComponent, + }) + + const PostComponent = () => { + const params = useParams({ strict: false }) + return ( + <> + + Params: {params().postId} + + + ) + } + + const postRoute = createRoute({ + getParentRoute: () => postsRoute, + path: '$postId', + component: PostComponent, + }) + + const router = createRouter({ + routeTree: rootRoute.addChildren([ + indexRoute, + layoutRoute.addChildren([postsRoute.addChildren([postRoute])]), + ]), + trailingSlash: trailingSlash ? 'always' : 'never', + }) + + render(() => ) + + const postsButton = await screen.findByTestId('posts-link') + + fireEvent.click(postsButton) + + await waitFor(() => { + expect(window.location.pathname).toEqual(`/posts${tail}`) + }) + + const firstPostButton = await screen.findByTestId('first-post-link') + + fireEvent.click(firstPostButton) + + await waitFor(() => { + expect(window.location.pathname).toEqual(`/posts/id1${tail}`) + }) + + const secondPostButton = await screen.findByTestId('second-post-link') + + fireEvent.click(secondPostButton) + + await waitFor(() => { + expect(window.location.pathname).toEqual(`/posts/id2${tail}`) + }) + }, + ) +}) + +describe('relative links to from route', () => { + test.each([true, false])( + 'should navigate to from route when using "." in nested route structure from Index Route with trailingSlash: %s', + async (trailingSlash: boolean) => { + const tail = trailingSlash ? '/' : '' + + const rootRoute = createRootRoute() + + const IndexComponent = () => { + return ( + <> + + Post + + + Search + + + Go To Home + + + + ) + } + + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: IndexComponent, + validateSearch: z.object({ + param1: z.string().optional(), + }), + }) + + const postRoute = createRoute({ + getParentRoute: () => indexRoute, + path: 'post', + component: () =>
Post
, + }) + + const router = createRouter({ + routeTree: rootRoute.addChildren([indexRoute, postRoute]), + history, + trailingSlash: trailingSlash ? 'always' : 'never', + }) + + render(() => ) + + const postButton = await screen.findByTestId('posts-link') + + fireEvent.click(postButton) + + await waitFor(() => { + expect(router.state.location.pathname).toBe(`/post${tail}`) + }) + + const searchButton = await screen.findByTestId('search-link') + + fireEvent.click(searchButton) + + await waitFor(() => { + expect(router.state.location.pathname).toBe(`/post${tail}`) + expect(router.state.location.search).toEqual({ param1: 'value1' }) + }) + + const homeBtn = await screen.findByTestId('home-link') + + fireEvent.click(homeBtn) + + await waitFor(() => { + expect(router.state.location.pathname).toBe(`/`) + expect(router.state.location.search).toEqual({}) + }) + }, + ) + + test.each([true, false])( + 'should navigate to from route with path params when using "." in nested route structure with trailingSlash: %s', + async (trailingSlash) => { + const tail = trailingSlash ? '/' : '' + const rootRoute = createRootRoute() + + const IndexComponent = () => { + return ( + <> +

Index

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

Layout

+ + + ) + }, + }) + + const PostsComponent = () => { + return ( + <> +

Posts

+ + To first post + + + To second post + + + To posts list + + + + ) + } + + const PostDetailComponent = () => { + return ( + <> +

Post Detail

+ + To post info + + + To post notes + + + To index detail options + + + + ) + } + + 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-link') + + fireEvent.click(postsButton) + + await waitFor(() => { + expect(screen.queryByTestId('posts-index-heading')).toBeInTheDocument() + expect(window.location.pathname).toEqual(`/posts${tail}`) + }) + + const firstPostButton = await screen.findByTestId('first-post-link') + + fireEvent.click(firstPostButton) + + await waitFor(() => { + expect(screen.queryByTestId('post-detail-index-heading')).toBeInTheDocument() + expect(window.location.pathname).toEqual(`/posts/1${tail}`) + }) + + const postInfoButton = await screen.findByTestId('post-info-link') + + fireEvent.click(postInfoButton) + + await waitFor(() => { + expect(screen.queryByTestId('post-info-heading')).toBeInTheDocument() + expect(window.location.pathname).toEqual(`/posts/1/info${tail}`) + }) + + const toPostDetailIndexButton = await screen.findByTestId('to-post-detail-index-link') + + fireEvent.click(toPostDetailIndexButton) + + await waitFor(() => { + expect(screen.queryByTestId('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-link') + + fireEvent.click(postNotesButton) + + await waitFor(() => { + expect(screen.queryByTestId('post-notes-heading')).toBeInTheDocument() + expect(window.location.pathname).toEqual(`/posts/1/notes${tail}`) + }) + + const toPostsIndexButton = await screen.findByTestId('to-posts-index-link') + + fireEvent.click(toPostsIndexButton) + + await waitFor(() => { + expect(screen.queryByTestId('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-link') + + fireEvent.click(secondPostButton) + + await waitFor(() => { + expect(screen.queryByTestId('post-detail-index-heading')).toBeInTheDocument() + expect(window.location.pathname).toEqual(`/posts/2${tail}`) + }) + }, + ) +}) + describe.each([{ basepath: '' }, { basepath: '/basepath' }])( 'relative links with %s', ({ basepath }) => { @@ -4636,7 +5242,7 @@ describe.each([{ basepath: '' }, { basepath: '/basepath' }])( return ( <>

Param Route

- Link to ./a + Link to ./a Link to c @@ -4656,7 +5262,7 @@ describe.each([{ basepath: '' }, { basepath: '/basepath' }])( return ( <>

Param A Route

- Link to .. from /param/foo/a + Link to .. from /param/foo/a ) @@ -4937,7 +5543,7 @@ describe.each([{ basepath: '' }, { basepath: '/basepath' }])( fireEvent.click(parentLink) - waitFor(() => + await waitFor(() => expect(window.location.pathname).toBe(`${basepath}/param/bar/a/b`), ) }) diff --git a/packages/solid-router/tests/useNavigate.test.tsx b/packages/solid-router/tests/useNavigate.test.tsx index cf9ef4831db..3c9aedc086a 100644 --- a/packages/solid-router/tests/useNavigate.test.tsx +++ b/packages/solid-router/tests/useNavigate.test.tsx @@ -1,6 +1,6 @@ import * as Solid from 'solid-js' import '@testing-library/jest-dom/vitest' -import { afterEach, describe, expect, test } from 'vitest' +import { afterEach, beforeEach, describe, expect, test } from 'vitest' import { cleanup, fireEvent, @@ -13,6 +13,7 @@ import { z } from 'zod' import { Outlet, RouterProvider, + createBrowserHistory, createRootRoute, createRoute, createRouteMask, @@ -21,6 +22,14 @@ import { useNavigate, useParams, } from '../src' +import type { RouterHistory } from '../src' + +let history: RouterHistory + +beforeEach(() => { + history = createBrowserHistory() + expect(window.location.pathname).toBe('/') +}) afterEach(() => { window.history.replaceState(null, 'root', '/') @@ -1310,156 +1319,942 @@ test('when setting search params with 2 parallel navigate calls', async () => { expect(search.get('param2')).toEqual('bar') }) -describe('when on /posts/$postId and navigating to ../ with default `from` /posts', () => { - async function runTest(navigateVia: 'Route' | 'RouteApi') { - const rootRoute = createRootRoute() - const IndexComponent = () => { - const navigate = useNavigate() - return ( - <> -

Index

- - - - ) - } +describe('relative navigate to current route', () => { + test.each([true, false])( + 'should navigate to current route with search params when using "." in nested route structure from Index Route with trailingSlash: %s', + async (trailingSlash: boolean) => { + const tail = trailingSlash ? '/' : '' - const indexRoute = createRoute({ - getParentRoute: () => rootRoute, - path: '/', - component: IndexComponent, - }) + const rootRoute = createRootRoute() - const layoutRoute = createRoute({ - getParentRoute: () => rootRoute, - id: '_layout', - component: () => { + const IndexComponent = () => { + const navigate = useNavigate() return ( <> -

Layout

+ + + ) - }, - }) + } - const PostsComponent = () => { - const routeNavigate = postsRoute.useNavigate() - const routeApiNavigate = getRouteApi('/_layout/posts').useNavigate() - return ( - <> -

Posts

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

Layout

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

Posts

+ - - - ) - } + > + To first post + + + + + ) + } - const postsRoute = createRoute({ - getParentRoute: () => layoutRoute, - path: 'posts', - component: PostsComponent, - }) + const postsRoute = createRoute({ + getParentRoute: () => layoutRoute, + path: 'posts', + component: PostsComponent, + }) - const PostComponent = () => { - const params = useParams({ strict: false }) - return ( - <> - Params: {params().postId} - - - ) - } + const PostComponent = () => { + const params = useParams({ strict: false }) + return ( + <> + + Params: {params().postId} + + + ) + } - const postRoute = createRoute({ - getParentRoute: () => postsRoute, - path: '$postId', - component: PostComponent, - }) + const postRoute = createRoute({ + getParentRoute: () => postsRoute, + path: '$postId', + component: PostComponent, + }) - const PostIndexComponent = () => { - return ( - <> -

Post Index

- - ) - } + const router = createRouter({ + routeTree: rootRoute.addChildren([ + indexRoute, + layoutRoute.addChildren([postsRoute.addChildren([postRoute])]), + ]), + trailingSlash: trailingSlash ? 'always' : 'never', + }) - const postIndexRoute = createRoute({ - getParentRoute: () => postRoute, - path: '/', - component: PostIndexComponent, - }) + render(() => ) - const DetailsComponent = () => { - return ( - <> -

Details!

- - ) - } + const postsButton = await screen.findByTestId('posts-btn') - const detailsRoute = createRoute({ - getParentRoute: () => postRoute, - path: 'details', - component: DetailsComponent, - }) + fireEvent.click(postsButton) + + await waitFor(() => { + expect(screen.queryByTestId('posts-index-heading')).toBeInTheDocument() + expect(window.location.pathname).toEqual(`/posts${tail}`) + }) + + const firstPostButton = await screen.findByTestId('first-post-btn') + + fireEvent.click(firstPostButton) + + await waitFor(() => { + expect(screen.queryByTestId('post-id1')).toBeInTheDocument() + expect(window.location.pathname).toEqual(`/posts/id1${tail}`) + }) + + const secondPostButton = await screen.findByTestId('second-post-btn') + + fireEvent.click(secondPostButton) + + await waitFor(() => { + expect(screen.queryByTestId('post-id2')).toBeInTheDocument() + expect(window.location.pathname).toEqual(`/posts/id2${tail}`) + }) + }, + ) + + test.each([true, false])( + 'should navigate to current route with search params when using "." in nested route structure from non-Index Route with trailingSlash: %s', + async (trailingSlash) => { + const tail = trailingSlash ? '/' : '' + const rootRoute = createRootRoute() + + const IndexComponent = () => { + const navigate = useNavigate() + return ( + <> +

Index

+ + + ) + } + + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: IndexComponent, + }) + + const PostsComponent = () => { + const navigate = postsRoute.useNavigate() + return ( + <> +

Posts

+ + + + ) + } + + const postsRoute = createRoute({ + getParentRoute: () => rootRoute, + path: 'posts', + component: PostsComponent, + }) + + const useModal = (name: string) => { + const currentOpen = postRoute.useSearch({ + select: (search) => search[`_${name}`], + }) + + const navigate = useNavigate() + + const setModal = (open: boolean) => { + navigate({ + to: '.', + search: (prev: {}) => ({ + ...prev, + [`_${name}`]: open ? true : undefined, + }), + resetScroll: false, + }) + } + + return [currentOpen, setModal] as const + } + + function DetailComponent(props: { id: string }) { + const params = useParams({ strict: false }) + const [currentTest, setTest] = useModal('test') + + return ( + <> +
+ Post Path "/{params().postId}/detail-{props.id}"! +
+ {currentTest() ? ( + + ) : ( + + )} + + ) + } + + const PostComponent = () => { + const params = useParams({ strict: false }) + + return ( +
+
Post "{params().postId}"!
+ + +
+ ) + } + + const postRoute = createRoute({ + getParentRoute: () => postsRoute, + path: '$postId', + component: PostComponent, + validateSearch: z.object({ + _test: z.boolean().optional(), + }), + }) + + const detailRoute = createRoute({ + getParentRoute: () => postRoute, + path: 'detail', + component: () => , + }) + + const router = createRouter({ + routeTree: rootRoute.addChildren([ + indexRoute, + postsRoute.addChildren([postRoute.addChildren([detailRoute])]), + ]), + trailingSlash: trailingSlash ? 'always' : 'never', + }) + + render(() => ) + + const postsButton = await screen.findByTestId('posts-btn') + + fireEvent.click(postsButton) + + await waitFor(() => { + expect(screen.queryByTestId('posts-index-heading')).toBeInTheDocument() + }) + + const post1Button = await screen.findByTestId('first-post-btn') + + fireEvent.click(post1Button) + + await waitFor(() => { + expect(screen.queryByTestId('post-heading')).toBeInTheDocument() + expect(screen.queryByTestId('detail-heading-1')).toBeInTheDocument() + expect(screen.queryByTestId('detail-heading-2')).toBeInTheDocument() + expect(screen.queryByTestId('detail-heading-1')).toHaveTextContent( + 'Post Path "/id1/detail-1', + ) + expect(screen.queryByTestId('detail-heading-2')).toHaveTextContent( + 'Post Path "/id1/detail-2', + ) + }) + + const detail1AddBtn = await screen.findByTestId('detail-btn-add-1') + + fireEvent.click(detail1AddBtn) + + await waitFor(() => { + expect(router.state.location.pathname).toBe(`/posts/id1/detail${tail}`) + expect(router.state.location.search).toEqual({ _test: true }) + }) + + const detail1RemoveBtn = await screen.findByTestId('detail-btn-remove-1') + + fireEvent.click(detail1RemoveBtn) + + await waitFor(() => { + expect(router.state.location.pathname).toBe(`/posts/id1/detail${tail}`) + expect(router.state.location.search).toEqual({}) + }) + + const detail2AddBtn = await screen.findByTestId('detail-btn-add-2') + + fireEvent.click(detail2AddBtn) + + await waitFor(() => { + expect(router.state.location.pathname).toBe(`/posts/id1/detail${tail}`) + expect(router.state.location.search).toEqual({ _test: true }) + }) + }, + ) +}) - const router = createRouter({ - routeTree: rootRoute.addChildren([ - indexRoute, - layoutRoute.addChildren([ - postsRoute.addChildren([ - postRoute.addChildren([postIndexRoute, detailsRoute]), +describe('relative navigate to from route', () => { + test.each([true, false])( + 'should navigate to from route when using "." in nested route structure from Index Route with trailingSlash: %s', + async (trailingSlash: boolean) => { + const tail = trailingSlash ? '/' : '' + + const rootRoute = createRootRoute() + + const IndexComponent = () => { + const navigate = useNavigate() + return ( + <> + + + + + + ) + } + + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: IndexComponent, + validateSearch: z.object({ + param1: z.string().optional(), + }), + }) + + const postRoute = createRoute({ + getParentRoute: () => indexRoute, + path: 'post', + component: () =>
Post
, + }) + + const router = createRouter({ + routeTree: rootRoute.addChildren([indexRoute, postRoute]), + history, + trailingSlash: trailingSlash ? 'always' : 'never', + }) + + render(() => ) + + const postButton = await screen.findByTestId('posts-btn') + + fireEvent.click(postButton) + + await waitFor(() => { + expect(router.state.location.pathname).toBe(`/post${tail}`) + }) + + const searchButton = await screen.findByTestId('search-btn') + + fireEvent.click(searchButton) + + await waitFor(() => { + expect(router.state.location.pathname).toBe(`/post${tail}`) + expect(router.state.location.search).toEqual({ param1: 'value1' }) + }) + + const homeBtn = await screen.findByTestId('home-btn') + + fireEvent.click(homeBtn) + + await waitFor(() => { + expect(router.state.location.pathname).toBe(`/`) + expect(router.state.location.search).toEqual({}) + }) + }, + ) + + test.each([true, false])( + 'should navigate to from route with path params when using "." in nested route structure with trailingSlash: %s', + 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) + + await waitFor(() => { + expect(screen.queryByTestId('posts-index-heading')).toBeInTheDocument() + expect(window.location.pathname).toEqual(`/posts${tail}`) + }) + + const firstPostButton = await screen.findByTestId('first-post-btn') + + fireEvent.click(firstPostButton) + + await waitFor(() => { + expect(screen.queryByTestId('post-detail-index-heading')).toBeInTheDocument() + expect(window.location.pathname).toEqual(`/posts/1${tail}`) + }) + + const postInfoButton = await screen.findByTestId('post-info-btn') + + fireEvent.click(postInfoButton) + + await waitFor(() => { + expect(screen.queryByTestId('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) + + await waitFor(() => { + expect(screen.queryByTestId('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) + + await waitFor(() => { + expect(screen.queryByTestId('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) + + await waitFor(() => { + expect(screen.queryByTestId('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) + + await waitFor(() => { + expect(screen.queryByTestId('post-detail-index-heading')).toBeInTheDocument() + expect(window.location.pathname).toEqual(`/posts/2${tail}`) + }) + }, + ) + + describe('when on /posts/$postId and navigating to ../ with default `from` /posts', () => { + async function runTest(navigateVia: 'Route' | 'RouteApi') { + 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 routeNavigate = postsRoute.useNavigate() + const routeApiNavigate = getRouteApi('/_layout/posts').useNavigate() + return ( + <> +

Posts

+ + + + ) + } + + const postsRoute = createRoute({ + getParentRoute: () => layoutRoute, + path: 'posts', + component: PostsComponent, + }) + + const PostComponent = () => { + const params = useParams({ strict: false }) + return ( + <> + Params: {params().postId} + + + ) + } + + const postRoute = createRoute({ + getParentRoute: () => postsRoute, + path: '$postId', + component: PostComponent, + }) + + const PostIndexComponent = () => { + return ( + <> +

Post Index

+ + ) + } + + const postIndexRoute = createRoute({ + getParentRoute: () => postRoute, + path: '/', + component: PostIndexComponent, + }) + + const DetailsComponent = () => { + return ( + <> +

Details!

+ + ) + } + + const detailsRoute = createRoute({ + getParentRoute: () => postRoute, + path: 'details', + component: DetailsComponent, + }) + + const router = createRouter({ + routeTree: rootRoute.addChildren([ + indexRoute, + layoutRoute.addChildren([ + postsRoute.addChildren([ + postRoute.addChildren([postIndexRoute, detailsRoute]), + ]), ]), ]), - ]), - }) + }) - render(() => ) + render(() => ) - const postsButton = await screen.findByTestId('index-to-first-post-btn') + const postsButton = await screen.findByTestId('index-to-first-post-btn') - fireEvent.click(postsButton) + fireEvent.click(postsButton) - expect(await screen.findByTestId('details-heading')).toBeInTheDocument() + expect(await screen.findByTestId('details-heading')).toBeInTheDocument() - expect(window.location.pathname).toEqual('/posts/id1/details') + expect(window.location.pathname).toEqual('/posts/id1/details') - const homeButton = await screen.findByTestId('btn-to-home') + const homeButton = await screen.findByTestId('btn-to-home') - fireEvent.click(homeButton) + fireEvent.click(homeButton) - expect(await screen.findByTestId('index-heading')).toBeInTheDocument() - expect(window.location.pathname).toEqual('/') - } + expect(await screen.findByTestId('index-heading')).toBeInTheDocument() + expect(window.location.pathname).toEqual('/') + } - test('Route', () => runTest('Route')) - test('RouteApi', () => runTest('RouteApi')) + test('Route', () => runTest('Route')) + test('RouteApi', () => runTest('RouteApi')) + }) }) + describe.each([{ basepath: '' }, { basepath: '/basepath' }])( 'relative useNavigate with %s', ({ basepath }) => { @@ -1509,7 +2304,7 @@ describe.each([{ basepath: '' }, { basepath: '/basepath' }])( return ( <>

Param Route

- From b4d41f89f755fa1f67485bd2a16d32f904d3fef2 Mon Sep 17 00:00:00 2001 From: Nico Lynzaad Date: Sun, 17 Aug 2025 15:31:46 +0200 Subject: [PATCH 07/25] update docs --- docs/router/framework/react/guide/navigation.md | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/docs/router/framework/react/guide/navigation.md b/docs/router/framework/react/guide/navigation.md index 26b8a02ce3f..538de8d4e9b 100644 --- a/docs/router/framework/react/guide/navigation.md +++ b/docs/router/framework/react/guide/navigation.md @@ -183,7 +183,7 @@ Keep in mind that normally dynamic segment params are `string` values, but they 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. -If you want to make a link that is relative to the current route, you can provide a `from` route path: +Relative links will always apply to the current location. To make it relative to another route, you can provide a `from` route path: ```tsx const postIdRoute = createRoute({ @@ -201,9 +201,9 @@ 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 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. -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 or another path. By specifying a `to` route path of `".."` navigation will be resolved to the first parent route preceding the current location. ```tsx export const Route = createFileRoute('/posts/$postId')({ @@ -214,7 +214,14 @@ 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 44ad37c8686c5c47b7fb9759cd30032b928fa275 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Sun, 17 Aug 2025 13:50:59 +0000 Subject: [PATCH 08/25] ci: apply automated fixes --- packages/react-router/src/link.tsx | 18 ++- packages/react-router/src/useNavigate.tsx | 2 +- packages/react-router/tests/link.test.tsx | 153 +++++++++--------- .../react-router/tests/useNavigate.test.tsx | 62 +++++-- packages/router-core/src/router.ts | 11 +- packages/solid-router/src/link.tsx | 11 +- packages/solid-router/src/useNavigate.tsx | 4 +- packages/solid-router/tests/link.test.tsx | 113 ++++++------- .../solid-router/tests/useNavigate.test.tsx | 42 +++-- 9 files changed, 236 insertions(+), 180 deletions(-) diff --git a/packages/react-router/src/link.tsx b/packages/react-router/src/link.tsx index 3b8553c5ad9..98831bc8712 100644 --- a/packages/react-router/src/link.tsx +++ b/packages/react-router/src/link.tsx @@ -104,14 +104,16 @@ export function useLinkProps< select: (match) => match.index, }) - const getFrom = React.useCallback( () => { - const currentRouteMatches= router.matchRoutes(router.latestLocation, { + const getFrom = React.useCallback(() => { + const currentRouteMatches = router.matchRoutes(router.latestLocation, { _buildLocation: false, }) - return options.from ?? + return ( + options.from ?? currentRouteMatches.slice(-1)[0]?.fullPath ?? router.state.matches[matchIndex]!.fullPath + ) }, [router, options.from, matchIndex]) const next = React.useMemo( @@ -192,10 +194,12 @@ export function useLinkProps< const doPreload = React.useCallback( () => { - router.preloadRoute({ ...options, from: getFrom() } as any).catch((err) => { - console.warn(err) - console.warn(preloadWarning) - }) + router + .preloadRoute({ ...options, from: getFrom() } as any) + .catch((err) => { + console.warn(err) + console.warn(preloadWarning) + }) }, // eslint-disable-next-line react-hooks/exhaustive-deps [ diff --git a/packages/react-router/src/useNavigate.tsx b/packages/react-router/src/useNavigate.tsx index b4a1e1d395c..985ed477528 100644 --- a/packages/react-router/src/useNavigate.tsx +++ b/packages/react-router/src/useNavigate.tsx @@ -26,7 +26,7 @@ export function useNavigate< return React.useCallback( (options: NavigateOptions) => { - const currentRouteMatches= router.matchRoutes(router.latestLocation, { + const currentRouteMatches = router.matchRoutes(router.latestLocation, { _buildLocation: false, }) diff --git a/packages/react-router/tests/link.test.tsx b/packages/react-router/tests/link.test.tsx index 5e4a900a322..2e2f32ed87d 100644 --- a/packages/react-router/tests/link.test.tsx +++ b/packages/react-router/tests/link.test.tsx @@ -5074,7 +5074,9 @@ describe.each([{ basepath: '' }, { basepath: '/basepath' }])( return ( <>

Param Route

- Link to ./a + + Link to ./a + Link to c @@ -5094,7 +5096,9 @@ describe.each([{ basepath: '' }, { basepath: '/basepath' }])( return ( <>

Param A Route

- Link to .. from /param/foo/a + + Link to .. from /param/foo/a + ) @@ -5420,23 +5424,20 @@ describe('relative links to current route', () => { const IndexComponent = () => { return ( <> - + Post Search Search2 @@ -5502,23 +5503,20 @@ describe('relative links to current route', () => { const IndexComponent = () => { return ( <> - + Post Search Search2 @@ -5582,10 +5580,7 @@ describe('relative links to current route', () => { return ( <>

Index

- + Posts @@ -5617,14 +5612,14 @@ describe('relative links to current route', () => {

Posts

To first post To second post @@ -5671,7 +5666,9 @@ describe('relative links to current route', () => { await act(() => fireEvent.click(postsButton)) - expect(await screen.findByTestId('posts-index-heading')).toBeInTheDocument() + expect( + await screen.findByTestId('posts-index-heading'), + ).toBeInTheDocument() expect(window.location.pathname).toEqual(`/posts${tail}`) const firstPostButton = await screen.findByTestId('first-post-link') @@ -5702,24 +5699,17 @@ describe('relative links to from route', () => { const IndexComponent = () => { return ( <> - + Post Search - + Go To Home @@ -5782,10 +5772,7 @@ describe('relative links to from route', () => { return ( <>

Index

- + Posts @@ -5817,23 +5804,19 @@ describe('relative links to from route', () => {

Posts

To first post To second post - + To posts list @@ -5845,22 +5828,16 @@ describe('relative links to from route', () => { return ( <>

Post Detail

- + To post info - + To post notes To index detail options @@ -5912,7 +5889,11 @@ describe('relative links to from route', () => { 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', }) @@ -5923,14 +5904,18 @@ describe('relative links to from route', () => { fireEvent.click(postsButton) - expect(await screen.findByTestId('posts-index-heading')).toBeInTheDocument() + expect( + await screen.findByTestId('posts-index-heading'), + ).toBeInTheDocument() expect(window.location.pathname).toEqual(`/posts${tail}`) const firstPostButton = await screen.findByTestId('first-post-link') 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-link') @@ -5940,28 +5925,36 @@ describe('relative links to from route', () => { 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-link') + const toPostDetailIndexButton = await screen.findByTestId( + 'to-post-detail-index-link', + ) 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-link') fireEvent.click(postNotesButton) - expect(await screen.findByTestId('post-notes-heading')).toBeInTheDocument() + 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-link') + const toPostsIndexButton = await screen.findByTestId( + 'to-posts-index-link', + ) fireEvent.click(toPostsIndexButton) - expect(await screen.findByTestId('posts-index-heading')).toBeInTheDocument() + expect( + await screen.findByTestId('posts-index-heading'), + ).toBeInTheDocument() expect( screen.queryByTestId("'post-notes-heading"), ).not.toBeInTheDocument() @@ -5974,7 +5967,9 @@ describe('relative links to from route', () => { 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}`) }, ) @@ -5988,10 +5983,12 @@ describe('when on /posts/$postId and navigating to ../ with default `from` /post return ( <>

Index

- Posts + + Posts + To first post @@ -6022,12 +6019,24 @@ describe('when on /posts/$postId and navigating to ../ with default `from` /post const PostsComponent = () => { const linkVia = () => { if (navigateVia === 'Route') { - return To Home + return ( + + To Home + + ) } const RouteApiLink = getRouteApi('/_layout/posts').Link - return To Home + return ( + + To Home + + ) } return ( diff --git a/packages/react-router/tests/useNavigate.test.tsx b/packages/react-router/tests/useNavigate.test.tsx index 51fe8d28ede..02e036901d4 100644 --- a/packages/react-router/tests/useNavigate.test.tsx +++ b/packages/react-router/tests/useNavigate.test.tsx @@ -1574,7 +1574,9 @@ describe('relative navigate to current route', () => { fireEvent.click(postsButton) - expect(await screen.findByTestId('posts-index-heading')).toBeInTheDocument() + expect( + await screen.findByTestId('posts-index-heading'), + ).toBeInTheDocument() expect(window.location.pathname).toEqual(`/posts${tail}`) const firstPostButton = await screen.findByTestId('first-post-btn') @@ -1740,7 +1742,9 @@ describe('relative navigate to current route', () => { await act(() => fireEvent.click(postsButton)) - expect(await screen.findByTestId('posts-index-heading')).toBeInTheDocument() + expect( + await screen.findByTestId('posts-index-heading'), + ).toBeInTheDocument() const post1Button = await screen.findByTestId('first-post-btn') @@ -1820,7 +1824,7 @@ describe('relative navigate to from route', () => { onClick={() => navigate({ from: '/', - to: '.' + to: '.', }) } > @@ -1948,7 +1952,7 @@ describe('relative navigate to from route', () => { onClick={() => navigate({ from: '/posts', - to: '.' + to: '.', }) } > @@ -1989,7 +1993,7 @@ describe('relative navigate to from route', () => { onClick={() => navigate({ from: '/posts/$postId', - to: '.' + to: '.', }) } > @@ -2043,7 +2047,11 @@ describe('relative navigate to from route', () => { 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', }) @@ -2054,14 +2062,18 @@ describe('relative navigate to from route', () => { fireEvent.click(postsButton) - expect(await screen.findByTestId('posts-index-heading')).toBeInTheDocument() + 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( + await screen.findByTestId('post-detail-index-heading'), + ).toBeInTheDocument() expect(window.location.pathname).toEqual(`/posts/1${tail}`) const postInfoButton = await screen.findByTestId('post-info-btn') @@ -2071,28 +2083,34 @@ describe('relative navigate to from route', () => { 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') fireEvent.click(postNotesButton) - expect(await screen.findByTestId('post-notes-heading')).toBeInTheDocument() + 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( + await screen.findByTestId('posts-index-heading'), + ).toBeInTheDocument() expect( screen.queryByTestId("'post-notes-heading"), ).not.toBeInTheDocument() @@ -2105,7 +2123,9 @@ describe('relative navigate to from route', () => { 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}`) }, ) @@ -2310,7 +2330,11 @@ describe.each([{ basepath: '' }, { basepath: '/basepath' }])( return ( <>

Param Route

- diff --git a/packages/router-core/src/router.ts b/packages/router-core/src/router.ts index 030ebd910bf..97c4bc984fc 100644 --- a/packages/router-core/src/router.ts +++ b/packages/router-core/src/router.ts @@ -1416,7 +1416,11 @@ export class RouterCore< // check that from path exists in the current route tree // do this check only on navigations during test or development - if (dest.from && process.env.NODE_ENV !== 'production' && dest._isNavigate) { + if ( + dest.from && + process.env.NODE_ENV !== 'production' && + dest._isNavigate + ) { const allFromMatches = this.getMatchedRoutes( dest.from, undefined, @@ -1443,7 +1447,10 @@ export class RouterCore< // This should default to the current location if no from is provided const lastMatch = last(allCurrentLocationMatches)! - const defaultedFromPath = dest.unsafeRelative === 'path' ? currentLocation.pathname : dest.from ?? lastMatch.fullPath + const defaultedFromPath = + dest.unsafeRelative === 'path' + ? currentLocation.pathname + : (dest.from ?? lastMatch.fullPath) // ensure this includes the basePath if set const fromPath = this.resolvePathWithBase(defaultedFromPath, '.') diff --git a/packages/solid-router/src/link.tsx b/packages/solid-router/src/link.tsx index 807a05579de..01676cacad4 100644 --- a/packages/solid-router/src/link.tsx +++ b/packages/solid-router/src/link.tsx @@ -146,18 +146,19 @@ export function useLinkProps< }) const _options = () => { - const currentRouteMatches= router.matchRoutes(router.latestLocation, { + const currentRouteMatches = router.matchRoutes(router.latestLocation, { _buildLocation: false, }) - const from = options.from ?? + const from = + options.from ?? currentRouteMatches.slice(-1)[0]?.fullPath ?? router.state.matches[matchIndex()]!.fullPath - return ({ + return { ...options, - from - }) + from, + } } const next = Solid.createMemo(() => { diff --git a/packages/solid-router/src/useNavigate.tsx b/packages/solid-router/src/useNavigate.tsx index 94c0374e223..5b447507146 100644 --- a/packages/solid-router/src/useNavigate.tsx +++ b/packages/solid-router/src/useNavigate.tsx @@ -23,7 +23,7 @@ export function useNavigate< }) return ((options: NavigateOptions) => { - const currentRouteMatches= router.matchRoutes(router.latestLocation, { + const currentRouteMatches = router.matchRoutes(router.latestLocation, { _buildLocation: false, }) @@ -33,7 +33,7 @@ export function useNavigate< options.from ?? _defaultOpts?.from ?? currentRouteMatches.slice(-1)[0]?.fullPath ?? - router.state.matches[matchIndex()]!.fullPath + router.state.matches[matchIndex()]!.fullPath, }) }) as UseNavigateResult } diff --git a/packages/solid-router/tests/link.test.tsx b/packages/solid-router/tests/link.test.tsx index 4afbacf1218..5202a0d0386 100644 --- a/packages/solid-router/tests/link.test.tsx +++ b/packages/solid-router/tests/link.test.tsx @@ -4602,23 +4602,20 @@ describe('relative links to current route', () => { const IndexComponent = () => { return ( <> - + Post Search Search2 @@ -4690,23 +4687,20 @@ describe('relative links to current route', () => { const IndexComponent = () => { return ( <> - + Post Search Search2 @@ -4776,10 +4770,7 @@ describe('relative links to current route', () => { return ( <>

Index

- + Posts @@ -4811,14 +4802,14 @@ describe('relative links to current route', () => {

Posts

To first post To second post @@ -4899,24 +4890,17 @@ describe('relative links to from route', () => { const IndexComponent = () => { return ( <> - + Post Search - + Go To Home @@ -4985,10 +4969,7 @@ describe('relative links to from route', () => { return ( <>

Index

- + Posts @@ -5020,23 +5001,19 @@ describe('relative links to from route', () => {

Posts

To first post To second post - + To posts list @@ -5048,22 +5025,16 @@ describe('relative links to from route', () => { return ( <>

Post Detail

- + To post info - + To post notes To index detail options @@ -5115,7 +5086,11 @@ describe('relative links to from route', () => { 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', }) @@ -5136,7 +5111,9 @@ describe('relative links to from route', () => { fireEvent.click(firstPostButton) await waitFor(() => { - expect(screen.queryByTestId('post-detail-index-heading')).toBeInTheDocument() + expect( + screen.queryByTestId('post-detail-index-heading'), + ).toBeInTheDocument() expect(window.location.pathname).toEqual(`/posts/1${tail}`) }) @@ -5149,12 +5126,16 @@ describe('relative links to from route', () => { expect(window.location.pathname).toEqual(`/posts/1/info${tail}`) }) - const toPostDetailIndexButton = await screen.findByTestId('to-post-detail-index-link') + const toPostDetailIndexButton = await screen.findByTestId( + 'to-post-detail-index-link', + ) fireEvent.click(toPostDetailIndexButton) await waitFor(() => { - expect(screen.queryByTestId('post-detail-index-heading')).toBeInTheDocument() + expect( + screen.queryByTestId('post-detail-index-heading'), + ).toBeInTheDocument() expect( screen.queryByTestId("'post-info-heading"), ).not.toBeInTheDocument() @@ -5170,7 +5151,9 @@ describe('relative links to from route', () => { expect(window.location.pathname).toEqual(`/posts/1/notes${tail}`) }) - const toPostsIndexButton = await screen.findByTestId('to-posts-index-link') + const toPostsIndexButton = await screen.findByTestId( + 'to-posts-index-link', + ) fireEvent.click(toPostsIndexButton) @@ -5190,7 +5173,9 @@ describe('relative links to from route', () => { fireEvent.click(secondPostButton) await waitFor(() => { - expect(screen.queryByTestId('post-detail-index-heading')).toBeInTheDocument() + expect( + screen.queryByTestId('post-detail-index-heading'), + ).toBeInTheDocument() expect(window.location.pathname).toEqual(`/posts/2${tail}`) }) }, @@ -5242,7 +5227,9 @@ describe.each([{ basepath: '' }, { basepath: '/basepath' }])( return ( <>

Param Route

- Link to ./a + + Link to ./a + Link to c @@ -5262,7 +5249,9 @@ describe.each([{ basepath: '' }, { basepath: '/basepath' }])( return ( <>

Param A Route

- Link to .. from /param/foo/a + + Link to .. from /param/foo/a + ) diff --git a/packages/solid-router/tests/useNavigate.test.tsx b/packages/solid-router/tests/useNavigate.test.tsx index 3c9aedc086a..676ab63c0ea 100644 --- a/packages/solid-router/tests/useNavigate.test.tsx +++ b/packages/solid-router/tests/useNavigate.test.tsx @@ -1319,7 +1319,6 @@ test('when setting search params with 2 parallel navigate calls', async () => { expect(search.get('param2')).toEqual('bar') }) - describe('relative navigate to current route', () => { test.each([true, false])( 'should navigate to current route with search params when using "." in nested route structure from Index Route with trailingSlash: %s', @@ -1793,7 +1792,7 @@ describe('relative navigate to from route', () => { onClick={() => navigate({ from: '/', - to: '.' + to: '.', }) } > @@ -1927,7 +1926,7 @@ describe('relative navigate to from route', () => { onClick={() => navigate({ from: '/posts', - to: '.' + to: '.', }) } > @@ -1968,7 +1967,7 @@ describe('relative navigate to from route', () => { onClick={() => navigate({ from: '/posts/$postId', - to: '.' + to: '.', }) } > @@ -2022,7 +2021,11 @@ describe('relative navigate to from route', () => { 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', }) @@ -2043,7 +2046,9 @@ describe('relative navigate to from route', () => { fireEvent.click(firstPostButton) await waitFor(() => { - expect(screen.queryByTestId('post-detail-index-heading')).toBeInTheDocument() + expect( + screen.queryByTestId('post-detail-index-heading'), + ).toBeInTheDocument() expect(window.location.pathname).toEqual(`/posts/1${tail}`) }) @@ -2056,12 +2061,16 @@ describe('relative navigate to from route', () => { 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) await waitFor(() => { - expect(screen.queryByTestId('post-detail-index-heading')).toBeInTheDocument() + expect( + screen.queryByTestId('post-detail-index-heading'), + ).toBeInTheDocument() expect( screen.queryByTestId("'post-info-heading"), ).not.toBeInTheDocument() @@ -2097,7 +2106,9 @@ describe('relative navigate to from route', () => { fireEvent.click(secondPostButton) await waitFor(() => { - expect(screen.queryByTestId('post-detail-index-heading')).toBeInTheDocument() + expect( + screen.queryByTestId('post-detail-index-heading'), + ).toBeInTheDocument() expect(window.location.pathname).toEqual(`/posts/2${tail}`) }) }, @@ -2254,7 +2265,6 @@ describe('relative navigate to from route', () => { }) }) - describe.each([{ basepath: '' }, { basepath: '/basepath' }])( 'relative useNavigate with %s', ({ basepath }) => { @@ -2304,7 +2314,11 @@ describe.each([{ basepath: '' }, { basepath: '/basepath' }])( return ( <>

Param Route

- From e0b8814b6a621ae94e56010e5cb93304fada0cf8 Mon Sep 17 00:00:00 2001 From: Nico Lynzaad Date: Sun, 17 Aug 2025 21:30:20 +0200 Subject: [PATCH 09/25] resolve merge issue --- packages/router-core/src/router.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/router-core/src/router.ts b/packages/router-core/src/router.ts index 30a74b1abea..65617e9dc0e 100644 --- a/packages/router-core/src/router.ts +++ b/packages/router-core/src/router.ts @@ -1428,7 +1428,7 @@ export class RouterCore< ).matchedRoutes const matchedFrom = findLast(allCurrentLocationMatches, (d) => { - return comparePaths(d.fullPath, fromPath) + return comparePaths(d.fullPath, dest.from!) }) const matchedCurrent = findLast(allFromMatches, (d) => { From 0c9b67a0fb5e7f63f745e0925ea8778eff498525 Mon Sep 17 00:00:00 2001 From: Nico Lynzaad Date: Sun, 17 Aug 2025 22:06:44 +0200 Subject: [PATCH 10/25] apply code rabbit suggestions --- packages/react-router/src/link.tsx | 2 ++ packages/react-router/src/useNavigate.tsx | 2 +- packages/react-router/tests/link.test.tsx | 8 ++++---- packages/react-router/tests/useNavigate.test.tsx | 8 ++++---- packages/router-core/src/router.ts | 10 +++++----- packages/solid-router/src/link.tsx | 6 +++++- packages/solid-router/tests/link.test.tsx | 6 +++--- packages/solid-router/tests/useNavigate.test.tsx | 7 ++++--- 8 files changed, 28 insertions(+), 21 deletions(-) diff --git a/packages/react-router/src/link.tsx b/packages/react-router/src/link.tsx index 98831bc8712..e455ed1d2f9 100644 --- a/packages/react-router/src/link.tsx +++ b/packages/react-router/src/link.tsx @@ -131,6 +131,7 @@ export function useLinkProps< options.state, options.mask, options.unsafeRelative, + getFrom, ], ) @@ -220,6 +221,7 @@ export function useLinkProps< options.replace, options.resetScroll, options.viewTransition, + getFrom ], ) diff --git a/packages/react-router/src/useNavigate.tsx b/packages/react-router/src/useNavigate.tsx index 985ed477528..f70f464236a 100644 --- a/packages/react-router/src/useNavigate.tsx +++ b/packages/react-router/src/useNavigate.tsx @@ -42,7 +42,7 @@ export function useNavigate< }) }, // eslint-disable-next-line react-hooks/exhaustive-deps - [_defaultOpts?.from, router.navigate, router.latestLocation], + [_defaultOpts?.from, router.navigate, router.latestLocation, matchIndex], ) as UseNavigateResult } diff --git a/packages/react-router/tests/link.test.tsx b/packages/react-router/tests/link.test.tsx index 34461d51b4c..52e982c8ecc 100644 --- a/packages/react-router/tests/link.test.tsx +++ b/packages/react-router/tests/link.test.tsx @@ -5682,7 +5682,7 @@ describe('relative links to current route', () => { await act(() => fireEvent.click(secondPostButton)) - // expect(await screen.findByTestId('post-id2')).toBeInTheDocument() + expect(await screen.findByTestId('post-id2')).toBeInTheDocument() expect(window.location.pathname).toEqual(`/posts/id2${tail}`) }, ) @@ -5934,7 +5934,7 @@ describe('relative links to from route', () => { expect( await screen.findByTestId('post-detail-index-heading'), ).toBeInTheDocument() - expect(screen.queryByTestId("'post-info-heading")).not.toBeInTheDocument() + expect(screen.queryByTestId('post-info-heading')).not.toBeInTheDocument() expect(window.location.pathname).toEqual(`/posts/1${tail}`) const postNotesButton = await screen.findByTestId('post-notes-link') @@ -5956,10 +5956,10 @@ describe('relative links to from route', () => { await screen.findByTestId('posts-index-heading'), ).toBeInTheDocument() expect( - screen.queryByTestId("'post-notes-heading"), + screen.queryByTestId('post-notes-heading'), ).not.toBeInTheDocument() expect( - screen.queryByTestId("'post-detail-index-heading"), + screen.queryByTestId('post-detail-index-heading'), ).not.toBeInTheDocument() expect(window.location.pathname).toEqual(`/posts${tail}`) diff --git a/packages/react-router/tests/useNavigate.test.tsx b/packages/react-router/tests/useNavigate.test.tsx index 02e036901d4..3a8b7272edb 100644 --- a/packages/react-router/tests/useNavigate.test.tsx +++ b/packages/react-router/tests/useNavigate.test.tsx @@ -1623,7 +1623,7 @@ describe('relative navigate to current route', () => { }) const PostsComponent = () => { - const navigate = postsRoute.useNavigate() + const navigate = useNavigate() return ( <>

Posts

@@ -2092,7 +2092,7 @@ describe('relative navigate to from route', () => { expect( await screen.findByTestId('post-detail-index-heading'), ).toBeInTheDocument() - expect(screen.queryByTestId("'post-info-heading")).not.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') @@ -2112,10 +2112,10 @@ describe('relative navigate to from route', () => { await screen.findByTestId('posts-index-heading'), ).toBeInTheDocument() expect( - screen.queryByTestId("'post-notes-heading"), + screen.queryByTestId('post-notes-heading'), ).not.toBeInTheDocument() expect( - screen.queryByTestId("'post-detail-index-heading"), + screen.queryByTestId('post-detail-index-heading'), ).not.toBeInTheDocument() expect(window.location.pathname).toEqual(`/posts${tail}`) diff --git a/packages/router-core/src/router.ts b/packages/router-core/src/router.ts index 65617e9dc0e..5134a7a402a 100644 --- a/packages/router-core/src/router.ts +++ b/packages/router-core/src/router.ts @@ -1415,6 +1415,10 @@ export class RouterCore< _buildLocation: true, }) + // Now let's find the starting pathname + // This should default to the current location if no from is provided + const lastMatch = last(allCurrentLocationMatches)! + // check that from path exists in the current route tree // do this check only on navigations during test or development if ( @@ -1432,7 +1436,7 @@ export class RouterCore< }) const matchedCurrent = findLast(allFromMatches, (d) => { - return comparePaths(d.fullPath, currentLocation.pathname) + return comparePaths(d.fullPath, lastMatch.fullPath) }) // for from to be invalid it shouldn't just be unmatched to currentLocation @@ -1442,10 +1446,6 @@ export class RouterCore< } } - // Now let's find the starting pathname - // This should default to the current location if no from is provided - const lastMatch = last(allCurrentLocationMatches)! - const defaultedFromPath = dest.unsafeRelative === 'path' ? currentLocation.pathname diff --git a/packages/solid-router/src/link.tsx b/packages/solid-router/src/link.tsx index 01676cacad4..1308040024c 100644 --- a/packages/solid-router/src/link.tsx +++ b/packages/solid-router/src/link.tsx @@ -145,8 +145,10 @@ export function useLinkProps< select: (match) => match.index, }) + const activeLocation = useRouterState({ select: (s) => s.location }) + const _options = () => { - const currentRouteMatches = router.matchRoutes(router.latestLocation, { + const currentRouteMatches = router.matchRoutes(activeLocation(), { _buildLocation: false, }) @@ -163,6 +165,8 @@ export function useLinkProps< const next = Solid.createMemo(() => { currentSearch() + // Access activeLocation to make this memo re-run on route changes + activeLocation() return router.buildLocation(_options() as any) }) diff --git a/packages/solid-router/tests/link.test.tsx b/packages/solid-router/tests/link.test.tsx index 21b045cbf1f..f53c6d46baa 100644 --- a/packages/solid-router/tests/link.test.tsx +++ b/packages/solid-router/tests/link.test.tsx @@ -5137,7 +5137,7 @@ describe('relative links to from route', () => { screen.queryByTestId('post-detail-index-heading'), ).toBeInTheDocument() expect( - screen.queryByTestId("'post-info-heading"), + screen.queryByTestId('post-info-heading'), ).not.toBeInTheDocument() expect(window.location.pathname).toEqual(`/posts/1${tail}`) }) @@ -5160,10 +5160,10 @@ describe('relative links to from route', () => { await waitFor(() => { expect(screen.queryByTestId('posts-index-heading')).toBeInTheDocument() expect( - screen.queryByTestId("'post-notes-heading"), + screen.queryByTestId('post-notes-heading'), ).not.toBeInTheDocument() expect( - screen.queryByTestId("'post-detail-index-heading"), + screen.queryByTestId('post-detail-index-heading'), ).not.toBeInTheDocument() expect(window.location.pathname).toEqual(`/posts${tail}`) }) diff --git a/packages/solid-router/tests/useNavigate.test.tsx b/packages/solid-router/tests/useNavigate.test.tsx index 676ab63c0ea..5d22b0adb76 100644 --- a/packages/solid-router/tests/useNavigate.test.tsx +++ b/packages/solid-router/tests/useNavigate.test.tsx @@ -32,6 +32,7 @@ beforeEach(() => { }) afterEach(() => { + history.destroy() window.history.replaceState(null, 'root', '/') cleanup() }) @@ -2072,7 +2073,7 @@ describe('relative navigate to from route', () => { screen.queryByTestId('post-detail-index-heading'), ).toBeInTheDocument() expect( - screen.queryByTestId("'post-info-heading"), + screen.queryByTestId('post-info-heading'), ).not.toBeInTheDocument() expect(window.location.pathname).toEqual(`/posts/1${tail}`) }) @@ -2093,10 +2094,10 @@ describe('relative navigate to from route', () => { await waitFor(() => { expect(screen.queryByTestId('posts-index-heading')).toBeInTheDocument() expect( - screen.queryByTestId("'post-notes-heading"), + screen.queryByTestId('post-notes-heading'), ).not.toBeInTheDocument() expect( - screen.queryByTestId("'post-detail-index-heading"), + screen.queryByTestId('post-detail-index-heading'), ).not.toBeInTheDocument() expect(window.location.pathname).toEqual(`/posts${tail}`) }) From 723540c4c112909164340f238f843ccff7ed14e9 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Sun, 17 Aug 2025 20:12:41 +0000 Subject: [PATCH 11/25] ci: apply automated fixes --- packages/react-router/src/link.tsx | 2 +- packages/react-router/tests/link.test.tsx | 4 +--- packages/react-router/tests/useNavigate.test.tsx | 4 +--- packages/router-core/src/router.ts | 12 ++++++------ 4 files changed, 9 insertions(+), 13 deletions(-) diff --git a/packages/react-router/src/link.tsx b/packages/react-router/src/link.tsx index e455ed1d2f9..45ab8e00965 100644 --- a/packages/react-router/src/link.tsx +++ b/packages/react-router/src/link.tsx @@ -221,7 +221,7 @@ export function useLinkProps< options.replace, options.resetScroll, options.viewTransition, - getFrom + getFrom, ], ) diff --git a/packages/react-router/tests/link.test.tsx b/packages/react-router/tests/link.test.tsx index 52e982c8ecc..55508040e43 100644 --- a/packages/react-router/tests/link.test.tsx +++ b/packages/react-router/tests/link.test.tsx @@ -5955,9 +5955,7 @@ describe('relative links to from route', () => { 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() diff --git a/packages/react-router/tests/useNavigate.test.tsx b/packages/react-router/tests/useNavigate.test.tsx index 3a8b7272edb..fe9b05b3278 100644 --- a/packages/react-router/tests/useNavigate.test.tsx +++ b/packages/react-router/tests/useNavigate.test.tsx @@ -2111,9 +2111,7 @@ describe('relative navigate to from route', () => { 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() diff --git a/packages/router-core/src/router.ts b/packages/router-core/src/router.ts index e5dda78fa4d..807c296abf9 100644 --- a/packages/router-core/src/router.ts +++ b/packages/router-core/src/router.ts @@ -1430,13 +1430,13 @@ export class RouterCore< undefined, ).matchedRoutes - const matchedFrom = findLast(allCurrentLocationMatches, (d) => { - return comparePaths(d.fullPath, dest.from!) - }) + const matchedFrom = findLast(allCurrentLocationMatches, (d) => { + return comparePaths(d.fullPath, dest.from!) + }) - const matchedCurrent = findLast(allFromMatches, (d) => { - return comparePaths(d.fullPath, lastMatch.fullPath) - }) + const matchedCurrent = findLast(allFromMatches, (d) => { + return comparePaths(d.fullPath, lastMatch.fullPath) + }) // for from to be invalid it shouldn't just be unmatched to currentLocation // but the currentLocation should also be unmatched to from From 944af34e835283cd69cbeecf518c9a505358856a Mon Sep 17 00:00:00 2001 From: Nico Lynzaad Date: Sun, 17 Aug 2025 22:15:55 +0200 Subject: [PATCH 12/25] apply code rabbit doc suggestion --- docs/router/framework/react/guide/navigation.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/router/framework/react/guide/navigation.md b/docs/router/framework/react/guide/navigation.md index 538de8d4e9b..a9abd1371c4 100644 --- a/docs/router/framework/react/guide/navigation.md +++ b/docs/router/framework/react/guide/navigation.md @@ -183,7 +183,7 @@ Keep in mind that normally dynamic segment params are `string` values, but they 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. -Relative links will always apply to the current location. To make it relative to another route, you can provide a `from` route path: +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. ```tsx const postIdRoute = createRoute({ From f5c5559247613107d6357dfda8bc8da43aab7c08 Mon Sep 17 00:00:00 2001 From: Nico Lynzaad Date: Sun, 17 Aug 2025 22:22:17 +0200 Subject: [PATCH 13/25] apply code rabbit doc suggestion --- docs/router/framework/react/guide/navigation.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/router/framework/react/guide/navigation.md b/docs/router/framework/react/guide/navigation.md index a9abd1371c4..3305a61bf3c 100644 --- a/docs/router/framework/react/guide/navigation.md +++ b/docs/router/framework/react/guide/navigation.md @@ -28,7 +28,7 @@ type ToOptions< TTo extends string = '', > = { // `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. - from: string + from?: string // `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. to: string // `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. From 0ae150e41e164b848f79e628c54f40738eabf377 Mon Sep 17 00:00:00 2001 From: Nico Lynzaad Date: Sun, 17 Aug 2025 23:22:40 +0200 Subject: [PATCH 14/25] reactivity and code cleanup --- packages/react-router/src/link.tsx | 64 +++++++++-------------- packages/react-router/src/useNavigate.tsx | 3 +- packages/solid-router/src/link.tsx | 12 +++-- packages/solid-router/src/useNavigate.tsx | 3 +- 4 files changed, 37 insertions(+), 45 deletions(-) diff --git a/packages/react-router/src/link.tsx b/packages/react-router/src/link.tsx index 45ab8e00965..f95866477c6 100644 --- a/packages/react-router/src/link.tsx +++ b/packages/react-router/src/link.tsx @@ -4,6 +4,7 @@ import { deepEqual, exactPathTest, functionalUpdate, + last, preloadWarning, removeTrailingSlash, } from '@tanstack/router-core' @@ -104,24 +105,28 @@ export function useLinkProps< select: (match) => match.index, }) - const getFrom = React.useCallback(() => { - const currentRouteMatches = router.matchRoutes(router.latestLocation, { - _buildLocation: false, - }) + // Track the active location to ensure recomputation on path changes + const activeLocation = useRouterState({ + select: (s) => s.location, + structuralSharing: true as any, + }) - return ( - options.from ?? - currentRouteMatches.slice(-1)[0]?.fullPath ?? - router.state.matches[matchIndex]!.fullPath - ) - }, [router, options.from, matchIndex]) + const _options = React.useMemo(() => { + const currentRouteMatches = router.matchRoutes(activeLocation, { + _buildLocation: false, + }) - const next = React.useMemo( - () => router.buildLocation({ ...options, from: getFrom() } as any), + const from = options.from ?? + last(currentRouteMatches)?.fullPath ?? + router.state.matches[matchIndex]!.fullPath + + return {...options, from} + }, // eslint-disable-next-line react-hooks/exhaustive-deps [ router, currentSearch, + activeLocation, options._fromLocation, options.from, options.hash, @@ -131,8 +136,12 @@ export function useLinkProps< options.state, options.mask, options.unsafeRelative, - getFrom, - ], + ] + ) + + const next = React.useMemo( + () => router.buildLocation({ ..._options } as any), + [router, _options] ) const isExternal = type === 'external' @@ -196,33 +205,13 @@ export function useLinkProps< const doPreload = React.useCallback( () => { router - .preloadRoute({ ...options, from: getFrom() } as any) + .preloadRoute({ ... _options } as any) .catch((err) => { console.warn(err) console.warn(preloadWarning) }) }, - // eslint-disable-next-line react-hooks/exhaustive-deps - [ - router, - options.to, - options._fromLocation, - options.from, - options.search, - options.hash, - options.params, - options.state, - options.mask, - options.unsafeRelative, - options.hashScrollIntoView, - options.href, - options.ignoreBlocker, - options.reloadDocument, - options.replace, - options.resetScroll, - options.viewTransition, - getFrom, - ], + [router, _options] ) const preloadViewportIoCallback = React.useCallback( @@ -274,8 +263,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({ - ...options, - from: getFrom(), + ..._options, replace, resetScroll, hashScrollIntoView, diff --git a/packages/react-router/src/useNavigate.tsx b/packages/react-router/src/useNavigate.tsx index f70f464236a..83b5f63298c 100644 --- a/packages/react-router/src/useNavigate.tsx +++ b/packages/react-router/src/useNavigate.tsx @@ -1,4 +1,5 @@ import * as React from 'react' +import { last } from '@tanstack/router-core' import { useRouter } from './useRouter' import { useMatch } from './useMatch' import type { @@ -33,7 +34,7 @@ export function useNavigate< const from = options.from ?? _defaultOpts?.from ?? - currentRouteMatches.slice(-1)[0]?.fullPath ?? + last(currentRouteMatches)?.fullPath ?? router.state.matches[matchIndex]!.fullPath return router.navigate({ diff --git a/packages/solid-router/src/link.tsx b/packages/solid-router/src/link.tsx index 1308040024c..cc5d7609a51 100644 --- a/packages/solid-router/src/link.tsx +++ b/packages/solid-router/src/link.tsx @@ -6,6 +6,7 @@ import { deepEqual, exactPathTest, functionalUpdate, + last, preloadWarning, removeTrailingSlash, } from '@tanstack/router-core' @@ -147,19 +148,20 @@ export function useLinkProps< const activeLocation = useRouterState({ select: (s) => s.location }) - const _options = () => { + const from = Solid.createMemo(() => { const currentRouteMatches = router.matchRoutes(activeLocation(), { _buildLocation: false, }) - const from = - options.from ?? - currentRouteMatches.slice(-1)[0]?.fullPath ?? + return options.from ?? + last(currentRouteMatches)?.fullPath ?? router.state.matches[matchIndex()]!.fullPath + }) + const _options = () => { return { ...options, - from, + from: from(), } } diff --git a/packages/solid-router/src/useNavigate.tsx b/packages/solid-router/src/useNavigate.tsx index 5b447507146..a0b99751cb7 100644 --- a/packages/solid-router/src/useNavigate.tsx +++ b/packages/solid-router/src/useNavigate.tsx @@ -1,4 +1,5 @@ import * as Solid from 'solid-js' +import { last } from '@tanstack/router-core' import { useRouter } from './useRouter' import { useMatch } from './useMatch' import type { @@ -32,7 +33,7 @@ export function useNavigate< from: options.from ?? _defaultOpts?.from ?? - currentRouteMatches.slice(-1)[0]?.fullPath ?? + last(currentRouteMatches)?.fullPath ?? router.state.matches[matchIndex()]!.fullPath, }) }) as UseNavigateResult From ad7eea5006a227dd4fbb1e056b6e4ff52f99175a Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Sun, 17 Aug 2025 22:12:12 +0000 Subject: [PATCH 15/25] ci: apply automated fixes --- packages/react-router/src/link.tsx | 33 ++++++++++++++---------------- packages/solid-router/src/link.tsx | 4 +++- 2 files changed, 18 insertions(+), 19 deletions(-) diff --git a/packages/react-router/src/link.tsx b/packages/react-router/src/link.tsx index f95866477c6..c867370290f 100644 --- a/packages/react-router/src/link.tsx +++ b/packages/react-router/src/link.tsx @@ -107,20 +107,22 @@ export function useLinkProps< // Track the active location to ensure recomputation on path changes const activeLocation = useRouterState({ - select: (s) => s.location, - structuralSharing: true as any, + select: (s) => s.location, + structuralSharing: true as any, }) - const _options = React.useMemo(() => { + const _options = React.useMemo( + () => { const currentRouteMatches = router.matchRoutes(activeLocation, { _buildLocation: false, }) - const from = options.from ?? + const from = + options.from ?? last(currentRouteMatches)?.fullPath ?? router.state.matches[matchIndex]!.fullPath - return {...options, from} + return { ...options, from } }, // eslint-disable-next-line react-hooks/exhaustive-deps [ @@ -136,12 +138,12 @@ export function useLinkProps< options.state, options.mask, options.unsafeRelative, - ] + ], ) const next = React.useMemo( () => router.buildLocation({ ..._options } as any), - [router, _options] + [router, _options], ) const isExternal = type === 'external' @@ -202,17 +204,12 @@ export function useLinkProps< }, }) - const doPreload = React.useCallback( - () => { - router - .preloadRoute({ ... _options } as any) - .catch((err) => { - console.warn(err) - console.warn(preloadWarning) - }) - }, - [router, _options] - ) + const doPreload = React.useCallback(() => { + router.preloadRoute({ ..._options } as any).catch((err) => { + console.warn(err) + console.warn(preloadWarning) + }) + }, [router, _options]) const preloadViewportIoCallback = React.useCallback( (entry: IntersectionObserverEntry | undefined) => { diff --git a/packages/solid-router/src/link.tsx b/packages/solid-router/src/link.tsx index cc5d7609a51..397abdd693d 100644 --- a/packages/solid-router/src/link.tsx +++ b/packages/solid-router/src/link.tsx @@ -153,9 +153,11 @@ export function useLinkProps< _buildLocation: false, }) - return options.from ?? + return ( + options.from ?? last(currentRouteMatches)?.fullPath ?? router.state.matches[matchIndex()]!.fullPath + ) }) const _options = () => { From 8f41f218f56610fdb2976ad18f83d28de22db440 Mon Sep 17 00:00:00 2001 From: Nico Lynzaad Date: Tue, 19 Aug 2025 00:12:47 +0200 Subject: [PATCH 16/25] cleanup tests --- packages/react-router/tests/link.test.tsx | 49 +- .../react-router/tests/useNavigate.test.tsx | 1539 +++++++++-------- packages/solid-router/tests/link.test.tsx | 1480 ++++++++-------- .../solid-router/tests/useNavigate.test.tsx | 1221 ++++++------- 4 files changed, 2190 insertions(+), 2099 deletions(-) diff --git a/packages/react-router/tests/link.test.tsx b/packages/react-router/tests/link.test.tsx index 55508040e43..7a8971b6a30 100644 --- a/packages/react-router/tests/link.test.tsx +++ b/packages/react-router/tests/link.test.tsx @@ -5099,6 +5099,9 @@ describe.each([{ basepath: '' }, { basepath: '/basepath' }])( Link to .. from /param/foo/a + + Link to .. from current active route + ) @@ -5285,6 +5288,27 @@ describe.each([{ basepath: '' }, { basepath: '/basepath' }])( expect(window.location.pathname).toBe(`${basepath}/param/foo`) }) + test('should navigate to a parent link based on active location', async () => { + const router = setupRouter() + + render() + + await act(async () => { + history.push(`${basepath}/param/foo/a/b`) + }) + + const relativeLink = await screen.findByTestId('link-to-previous') + + expect(relativeLink.getAttribute('href')).toBe(`${basepath}/param/foo/a`) + + // Click the link and ensure the new location + await act(async () => { + fireEvent.click(relativeLink) + }) + + expect(window.location.pathname).toBe(`${basepath}/param/foo/a`) + }) + test('should navigate to a child link based on pathname', async () => { const router = setupRouter() @@ -6015,21 +6039,14 @@ describe('when on /posts/$postId and navigating to ../ with default `from` /post }) const PostsComponent = () => { - const linkVia = () => { - if (navigateVia === 'Route') { - return ( - - To Home - - ) - } + const LinkViaRoute = () => ( + + To Home + + ) + const LinkViaRouteApi = () => { const RouteApiLink = getRouteApi('/_layout/posts').Link - return ( To Home @@ -6040,7 +6057,11 @@ describe('when on /posts/$postId and navigating to ../ with default `from` /post return ( <>

Posts

- {linkVia()} + { + navigateVia === 'Route' + ? + : + } ) diff --git a/packages/react-router/tests/useNavigate.test.tsx b/packages/react-router/tests/useNavigate.test.tsx index fe9b05b3278..ebd2f0a21df 100644 --- a/packages/react-router/tests/useNavigate.test.tsx +++ b/packages/react-router/tests/useNavigate.test.tsx @@ -1058,13 +1058,13 @@ test('when navigating from /invoices to ./invoiceId and the current route is /po name: 'To first post', }) - await act(() => fireEvent.click(postsButton)) + fireEvent.click(postsButton) const invoicesButton = await screen.findByRole('button', { name: 'To Invoices', }) - await act(() => fireEvent.click(invoicesButton)) + fireEvent.click(invoicesButton) expect(consoleWarn).toHaveBeenCalledWith( 'Could not find match for from: /invoices', @@ -1366,917 +1366,912 @@ test(' navigates only once in ', async () => { expect(navigateSpy.mock.calls.length).toBe(1) }) -describe('relative navigate to current route', () => { - test.each([true, false])( - 'should navigate to current route with search params when using "." in nested route structure from Index Route with trailingSlash: %s', - async (trailingSlash: boolean) => { - const tail = trailingSlash ? '/' : '' - - const rootRoute = createRootRoute() - - const IndexComponent = () => { - const navigate = useNavigate() - return ( - <> - - - - - - ) - } +test.each([true, false])( + 'should navigate to current route with search params when using "." in nested route structure from Index Route', + async (trailingSlash: boolean) => { + const tail = trailingSlash ? '/' : '' - const indexRoute = createRoute({ - getParentRoute: () => rootRoute, - path: '/', - component: IndexComponent, - validateSearch: z.object({ - param1: z.string().optional(), - }), - }) + const rootRoute = createRootRoute() - const postRoute = createRoute({ - getParentRoute: () => indexRoute, - path: 'post', - component: () =>
Post
, - }) + const IndexComponent = () => { + const navigate = useNavigate() + return ( + <> + + + + + + ) + } - const router = createRouter({ - routeTree: rootRoute.addChildren([indexRoute, postRoute]), - history, - trailingSlash: trailingSlash ? 'always' : 'never', - }) + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: IndexComponent, + validateSearch: z.object({ + param1: z.string().optional(), + }), + }) - render() + const postRoute = createRoute({ + getParentRoute: () => indexRoute, + path: 'post', + component: () =>
Post
, + }) - const postButton = await screen.findByTestId('posts-btn') + const router = createRouter({ + routeTree: rootRoute.addChildren([indexRoute, postRoute]), + history, + trailingSlash: trailingSlash ? 'always' : 'never', + }) - await act(() => fireEvent.click(postButton)) + render() - expect(router.state.location.pathname).toBe(`/post${tail}`) + const postButton = await screen.findByTestId('posts-btn') - const searchButton = await screen.findByTestId('search-btn') + fireEvent.click(postButton) - await act(() => fireEvent.click(searchButton)) + expect(router.state.location.pathname).toBe(`/post${tail}`) - expect(router.state.location.pathname).toBe(`/post${tail}`) - expect(router.state.location.search).toEqual({ param1: 'value1' }) + const searchButton = await screen.findByTestId('search-btn') - const searchButton2 = await screen.findByTestId('search2-btn') + fireEvent.click(searchButton) - await act(() => fireEvent.click(searchButton2)) + expect(router.state.location.pathname).toBe(`/post${tail}`) + expect(router.state.location.search).toEqual({ param1: 'value1' }) - expect(router.state.location.pathname).toBe(`/post${tail}`) - expect(router.state.location.search).toEqual({ param1: 'value2' }) - }, - ) + const searchButton2 = await screen.findByTestId('search2-btn') - test.each([true, false])( - 'should navigate to current route with changing path params when using "." in nested route structure with trailingSlash: %s', - async (trailingSlash) => { - const tail = trailingSlash ? '/' : '' - const rootRoute = createRootRoute() + fireEvent.click(searchButton2) - const IndexComponent = () => { - const navigate = useNavigate() - return ( - <> -

Index

- - - ) - } + expect(router.state.location.pathname).toBe(`/post${tail}`) + expect(router.state.location.search).toEqual({ param1: 'value2' }) + }, +) - const indexRoute = createRoute({ - getParentRoute: () => rootRoute, - path: '/', - component: IndexComponent, - }) +test.each([true, false])( + 'should navigate to current route with changing path params when using "." in nested route structure', + async (trailingSlash) => { + const tail = trailingSlash ? '/' : '' + const rootRoute = createRootRoute() - const layoutRoute = createRoute({ - getParentRoute: () => rootRoute, - id: '_layout', - component: () => { - return ( - <> -

Layout

- - - ) - }, - }) + const IndexComponent = () => { + const navigate = useNavigate() + return ( + <> +

Index

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

Posts

- - +

Layout

) - } + }, + }) - const postsRoute = createRoute({ - getParentRoute: () => layoutRoute, - path: 'posts', - component: PostsComponent, - }) + const PostsComponent = () => { + const navigate = useNavigate() - const PostComponent = () => { - const params = useParams({ strict: false }) - return ( - <> - - Params: {params.postId} - - - ) - } + return ( + <> +

Posts

+ + + + + ) + } - const postRoute = createRoute({ - getParentRoute: () => postsRoute, - path: '$postId', - component: PostComponent, - }) + const postsRoute = createRoute({ + getParentRoute: () => layoutRoute, + path: 'posts', + component: PostsComponent, + }) - const router = createRouter({ - routeTree: rootRoute.addChildren([ - indexRoute, - layoutRoute.addChildren([postsRoute.addChildren([postRoute])]), - ]), - trailingSlash: trailingSlash ? 'always' : 'never', - }) + const PostComponent = () => { + const params = useParams({ strict: false }) + return ( + <> + + Params: {params.postId} + + + ) + } - render() + const postRoute = createRoute({ + getParentRoute: () => postsRoute, + path: '$postId', + component: PostComponent, + }) - const postsButton = await screen.findByTestId('posts-btn') + const router = createRouter({ + routeTree: rootRoute.addChildren([ + indexRoute, + layoutRoute.addChildren([postsRoute.addChildren([postRoute])]), + ]), + trailingSlash: trailingSlash ? 'always' : 'never', + }) - fireEvent.click(postsButton) + render() - expect( - await screen.findByTestId('posts-index-heading'), - ).toBeInTheDocument() - expect(window.location.pathname).toEqual(`/posts${tail}`) + const postsButton = await screen.findByTestId('posts-btn') - const firstPostButton = await screen.findByTestId('first-post-btn') + fireEvent.click(postsButton) - fireEvent.click(firstPostButton) + expect( + await screen.findByTestId('posts-index-heading'), + ).toBeInTheDocument() + expect(window.location.pathname).toEqual(`/posts${tail}`) - expect(await screen.findByTestId('post-id1')).toBeInTheDocument() - expect(window.location.pathname).toEqual(`/posts/id1${tail}`) + const firstPostButton = await screen.findByTestId('first-post-btn') - const secondPostButton = await screen.findByTestId('second-post-btn') + fireEvent.click(firstPostButton) - fireEvent.click(secondPostButton) + expect(await screen.findByTestId('post-id1')).toBeInTheDocument() + expect(window.location.pathname).toEqual(`/posts/id1${tail}`) - expect(await screen.findByTestId('post-id2')).toBeInTheDocument() - expect(window.location.pathname).toEqual(`/posts/id2${tail}`) - }, - ) + const secondPostButton = await screen.findByTestId('second-post-btn') - test.each([true, false])( - 'should navigate to current route with search params when using "." in nested route structure from non-Index Route with trailingSlash: %s', - async (trailingSlash) => { - const tail = trailingSlash ? '/' : '' - const rootRoute = createRootRoute() + fireEvent.click(secondPostButton) - const IndexComponent = () => { - const navigate = useNavigate() - return ( - <> -

Index

- - - ) - } - - const indexRoute = createRoute({ - getParentRoute: () => rootRoute, - path: '/', - component: IndexComponent, - }) + expect(await screen.findByTestId('post-id2')).toBeInTheDocument() + expect(window.location.pathname).toEqual(`/posts/id2${tail}`) + }, +) - const PostsComponent = () => { - const navigate = useNavigate() - return ( - <> -

Posts

- - - - ) - } +test.each([true, false])( + 'should navigate to current route with search params when using "." in nested route structure from non-Index Route', + async (trailingSlash) => { + const tail = trailingSlash ? '/' : '' + const rootRoute = createRootRoute() - const postsRoute = createRoute({ - getParentRoute: () => rootRoute, - path: 'posts', - component: PostsComponent, - }) + const IndexComponent = () => { + const navigate = useNavigate() + return ( + <> +

Index

+ + + ) + } - const useModal = (name: string) => { - const currentOpen = postRoute.useSearch({ - select: (search) => search[`_${name}`], - }) + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: IndexComponent, + }) - const navigate = useNavigate() + const PostsComponent = () => { + const navigate = useNavigate() + return ( + <> +

Posts

+ + + + ) + } - const setModal = React.useCallback( - (open: boolean) => { - navigate({ - to: '.', - search: (prev: {}) => ({ - ...prev, - [`_${name}`]: open ? true : undefined, - }), - resetScroll: false, - }) - }, - [name, navigate], - ) + const postsRoute = createRoute({ + getParentRoute: () => rootRoute, + path: 'posts', + component: PostsComponent, + }) - return [currentOpen, setModal] as const - } + const useModal = (name: string) => { + const currentOpen = postRoute.useSearch({ + select: (search) => search[`_${name}`], + }) - function DetailComponent(props: { id: string }) { - const params = useParams({ strict: false }) - const [currentTest, setTest] = useModal('test') + const navigate = useNavigate() + + const setModal = React.useCallback( + (open: boolean) => { + navigate({ + to: '.', + search: (prev: {}) => ({ + ...prev, + [`_${name}`]: open ? true : undefined, + }), + resetScroll: false, + }) + }, + [name, navigate], + ) - return ( - <> -
- Post Path "/{params.postId}/detail-{props.id}"! -
- {currentTest ? ( - - ) : ( - - )} - - ) - } + return [currentOpen, setModal] as const + } - const PostComponent = () => { - const params = useParams({ strict: false }) + function DetailComponent(props: { id: string }) { + const params = useParams({ strict: false }) + const [currentTest, setTest] = useModal('test') - return ( -
-
Post "{params.postId}"!
- - + return ( + <> +
+ Post Path "/{params.postId}/detail-{props.id}"!
- ) - } - - const postRoute = createRoute({ - getParentRoute: () => postsRoute, - path: '$postId', - component: PostComponent, - validateSearch: z.object({ - _test: z.boolean().optional(), - }), - }) + {currentTest ? ( + + ) : ( + + )} + + ) + } - const detailRoute = createRoute({ - getParentRoute: () => postRoute, - path: 'detail', - component: () => , - }) + const PostComponent = () => { + const params = useParams({ strict: false }) - const router = createRouter({ - routeTree: rootRoute.addChildren([ - indexRoute, - postsRoute.addChildren([postRoute.addChildren([detailRoute])]), - ]), - trailingSlash: trailingSlash ? 'always' : 'never', - }) + return ( +
+
Post "{params.postId}"!
+ + +
+ ) + } - render() + const postRoute = createRoute({ + getParentRoute: () => postsRoute, + path: '$postId', + component: PostComponent, + validateSearch: z.object({ + _test: z.boolean().optional(), + }), + }) - const postsButton = await screen.findByTestId('posts-btn') + const detailRoute = createRoute({ + getParentRoute: () => postRoute, + path: 'detail', + component: () => , + }) - await act(() => fireEvent.click(postsButton)) + const router = createRouter({ + routeTree: rootRoute.addChildren([ + indexRoute, + postsRoute.addChildren([postRoute.addChildren([detailRoute])]), + ]), + trailingSlash: trailingSlash ? 'always' : 'never', + }) - expect( - await screen.findByTestId('posts-index-heading'), - ).toBeInTheDocument() + render() - const post1Button = await screen.findByTestId('first-post-btn') + const postsButton = await screen.findByTestId('posts-btn') - await act(() => fireEvent.click(post1Button)) + fireEvent.click(postsButton) - expect(await screen.findByTestId('post-heading')).toBeInTheDocument() - expect(await screen.findByTestId('detail-heading-1')).toBeInTheDocument() - expect(await screen.findByTestId('detail-heading-2')).toBeInTheDocument() - expect(await screen.findByTestId('detail-heading-1')).toHaveTextContent( - 'Post Path "/id1/detail-1', - ) - expect(await screen.findByTestId('detail-heading-2')).toHaveTextContent( - 'Post Path "/id1/detail-2', - ) + expect( + await screen.findByTestId('posts-index-heading'), + ).toBeInTheDocument() - const detail1AddBtn = await screen.findByTestId('detail-btn-add-1') + const post1Button = await screen.findByTestId('first-post-btn') - await act(() => fireEvent.click(detail1AddBtn)) + fireEvent.click(post1Button) + expect(await screen.findByTestId('post-heading')).toBeInTheDocument() + expect(await screen.findByTestId('detail-heading-1')).toBeInTheDocument() + expect(await screen.findByTestId('detail-heading-2')).toBeInTheDocument() + expect(await screen.findByTestId('detail-heading-1')).toHaveTextContent( + 'Post Path "/id1/detail-1', + ) + expect(await screen.findByTestId('detail-heading-2')).toHaveTextContent( + 'Post Path "/id1/detail-2', + ) - expect(router.state.location.pathname).toBe(`/posts/id1/detail${tail}`) - expect(router.state.location.search).toEqual({ _test: true }) + const detail1AddBtn = await screen.findByTestId('detail-btn-add-1') - const detail1RemoveBtn = await screen.findByTestId('detail-btn-remove-1') + fireEvent.click(detail1AddBtn) - await act(() => fireEvent.click(detail1RemoveBtn)) + expect(router.state.location.pathname).toBe(`/posts/id1/detail${tail}`) + expect(router.state.location.search).toEqual({ _test: true }) - expect(router.state.location.pathname).toBe(`/posts/id1/detail${tail}`) - expect(router.state.location.search).toEqual({}) + const detail1RemoveBtn = await screen.findByTestId('detail-btn-remove-1') - const detail2AddBtn = await screen.findByTestId('detail-btn-add-2') + fireEvent.click(detail1RemoveBtn) - await act(() => fireEvent.click(detail2AddBtn)) + expect(router.state.location.pathname).toBe(`/posts/id1/detail${tail}`) + expect(router.state.location.search).toEqual({}) - expect(router.state.location.pathname).toBe(`/posts/id1/detail${tail}`) - expect(router.state.location.search).toEqual({ _test: true }) - }, - ) -}) + const detail2AddBtn = await screen.findByTestId('detail-btn-add-2') -describe('relative navigate to from route', () => { - test.each([true, false])( - 'should navigate to from route when using "." in nested route structure from Index Route with trailingSlash: %s', - async (trailingSlash: boolean) => { - const tail = trailingSlash ? '/' : '' + fireEvent.click(detail2AddBtn) - const rootRoute = createRootRoute() + expect(router.state.location.pathname).toBe(`/posts/id1/detail${tail}`) + expect(router.state.location.search).toEqual({ _test: true }) + }, +) - const IndexComponent = () => { - const navigate = useNavigate() - return ( - <> - - - - - - ) - } +test.each([true, false])( + 'should navigate to from route when using "." in nested route structure from Index Route with trailingSlash: %s', + async (trailingSlash: boolean) => { + const tail = trailingSlash ? '/' : '' - const indexRoute = createRoute({ - getParentRoute: () => rootRoute, - path: '/', - component: IndexComponent, - validateSearch: z.object({ - param1: z.string().optional(), - }), - }) + const rootRoute = createRootRoute() - const postRoute = createRoute({ - getParentRoute: () => indexRoute, - path: 'post', - component: () =>
Post
, - }) + const IndexComponent = () => { + const navigate = useNavigate() + return ( + <> + + + + + + ) + } - const router = createRouter({ - routeTree: rootRoute.addChildren([indexRoute, postRoute]), - history, - trailingSlash: trailingSlash ? 'always' : 'never', - }) + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: IndexComponent, + validateSearch: z.object({ + param1: z.string().optional(), + }), + }) - render() + const postRoute = createRoute({ + getParentRoute: () => indexRoute, + path: 'post', + component: () =>
Post
, + }) - const postButton = await screen.findByTestId('posts-btn') + const router = createRouter({ + routeTree: rootRoute.addChildren([indexRoute, postRoute]), + history, + trailingSlash: trailingSlash ? 'always' : 'never', + }) - await act(() => fireEvent.click(postButton)) + render() - expect(router.state.location.pathname).toBe(`/post${tail}`) + const postButton = await screen.findByTestId('posts-btn') - const searchButton = await screen.findByTestId('search-btn') + await act(() => fireEvent.click(postButton)) - await act(() => fireEvent.click(searchButton)) + expect(router.state.location.pathname).toBe(`/post${tail}`) - expect(router.state.location.pathname).toBe(`/post${tail}`) - expect(router.state.location.search).toEqual({ param1: 'value1' }) + const searchButton = await screen.findByTestId('search-btn') - const homeBtn = await screen.findByTestId('home-btn') + await act(() => fireEvent.click(searchButton)) - await act(() => fireEvent.click(homeBtn)) + expect(router.state.location.pathname).toBe(`/post${tail}`) + expect(router.state.location.search).toEqual({ param1: 'value1' }) - expect(router.state.location.pathname).toBe(`/`) - expect(router.state.location.search).toEqual({}) - }, - ) + const homeBtn = await screen.findByTestId('home-btn') - test.each([true, false])( - 'should navigate to from route with path params when using "." in nested route structure with trailingSlash: %s', - async (trailingSlash) => { - const tail = trailingSlash ? '/' : '' - const rootRoute = createRootRoute() + await act(() => fireEvent.click(homeBtn)) - const IndexComponent = () => { - const navigate = useNavigate() - return ( - <> -

Index

- - - ) - } + expect(router.state.location.pathname).toBe(`/`) + expect(router.state.location.search).toEqual({}) + } +) - const indexRoute = createRoute({ - getParentRoute: () => rootRoute, - path: '/', - component: IndexComponent, - }) +test.each([true, false])( + 'should navigate to from route with path params when using "." in nested route structure with trailingSlash: %s', + async (trailingSlash) => { + const tail = trailingSlash ? '/' : '' + const rootRoute = createRootRoute() - const layoutRoute = createRoute({ - getParentRoute: () => rootRoute, - id: '_layout', - component: () => { - return ( - <> -

Layout

- - - ) - }, - }) + const IndexComponent = () => { + const navigate = useNavigate() + return ( + <> +

Index

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

Posts

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

Post Detail

- - - +

Layout

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

Post Info

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

Posts

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

Post Notes

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

Post Detail

+ + + + + + ) + } - const postsRoute = createRoute({ - getParentRoute: () => layoutRoute, - path: 'posts', - component: PostsComponent, - }) + const PostInfoComponent = () => { + return ( + <> +

Post Info

+ + ) + } - const postDetailRoute = createRoute({ - getParentRoute: () => postsRoute, - path: '$postId', - component: PostDetailComponent, - }) + const PostNotesComponent = () => { + return ( + <> +

Post Notes

+ + ) + } - const postInfoRoute = createRoute({ - getParentRoute: () => postDetailRoute, - path: 'info', - component: PostInfoComponent, - }) + const postsRoute = createRoute({ + getParentRoute: () => layoutRoute, + path: 'posts', + component: PostsComponent, + }) - const postNotesRoute = createRoute({ - getParentRoute: () => postDetailRoute, - path: 'notes', - component: PostNotesComponent, - }) + const postDetailRoute = createRoute({ + getParentRoute: () => postsRoute, + path: '$postId', + component: PostDetailComponent, + }) - const router = createRouter({ - routeTree: rootRoute.addChildren([ - indexRoute, - layoutRoute.addChildren([ - postsRoute.addChildren([ - postDetailRoute.addChildren([postInfoRoute, postNotesRoute]), - ]), + 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() + ]), + trailingSlash: trailingSlash ? 'always' : 'never', + }) - const postsButton = await screen.findByTestId('posts-btn') + render() - fireEvent.click(postsButton) + const postsButton = await screen.findByTestId('posts-btn') - expect( - await screen.findByTestId('posts-index-heading'), - ).toBeInTheDocument() - expect(window.location.pathname).toEqual(`/posts${tail}`) + fireEvent.click(postsButton) - const firstPostButton = await screen.findByTestId('first-post-btn') + expect( + await screen.findByTestId('posts-index-heading'), + ).toBeInTheDocument() + expect(window.location.pathname).toEqual(`/posts${tail}`) - fireEvent.click(firstPostButton) + const firstPostButton = await screen.findByTestId('first-post-btn') - expect( - await screen.findByTestId('post-detail-index-heading'), - ).toBeInTheDocument() - expect(window.location.pathname).toEqual(`/posts/1${tail}`) + fireEvent.click(firstPostButton) - const postInfoButton = await screen.findByTestId('post-info-btn') + expect( + await screen.findByTestId('post-detail-index-heading'), + ).toBeInTheDocument() + expect(window.location.pathname).toEqual(`/posts/1${tail}`) - fireEvent.click(postInfoButton) + const postInfoButton = await screen.findByTestId('post-info-btn') - expect(await screen.findByTestId('post-info-heading')).toBeInTheDocument() - expect(window.location.pathname).toEqual(`/posts/1/info${tail}`) + fireEvent.click(postInfoButton) - const toPostDetailIndexButton = await screen.findByTestId( - 'to-post-detail-index-btn', - ) + expect(await screen.findByTestId('post-info-heading')).toBeInTheDocument() + expect(window.location.pathname).toEqual(`/posts/1/info${tail}`) - fireEvent.click(toPostDetailIndexButton) + const toPostDetailIndexButton = await screen.findByTestId( + 'to-post-detail-index-btn', + ) - 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}`) + fireEvent.click(toPostDetailIndexButton) - const postNotesButton = await screen.findByTestId('post-notes-btn') + 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}`) - fireEvent.click(postNotesButton) + const postNotesButton = await screen.findByTestId('post-notes-btn') - expect( - await screen.findByTestId('post-notes-heading'), - ).toBeInTheDocument() - expect(window.location.pathname).toEqual(`/posts/1/notes${tail}`) + fireEvent.click(postNotesButton) - const toPostsIndexButton = await screen.findByTestId('to-posts-index-btn') + expect( + await screen.findByTestId('post-notes-heading'), + ).toBeInTheDocument() + expect(window.location.pathname).toEqual(`/posts/1/notes${tail}`) - fireEvent.click(toPostsIndexButton) + const toPostsIndexButton = await screen.findByTestId('to-posts-index-btn') - 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}`) + fireEvent.click(toPostsIndexButton) - const secondPostButton = await screen.findByTestId('second-post-btn') + 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}`) - fireEvent.click(secondPostButton) + const secondPostButton = await screen.findByTestId('second-post-btn') - expect( - await screen.findByTestId('post-detail-index-heading'), - ).toBeInTheDocument() - expect(window.location.pathname).toEqual(`/posts/2${tail}`) - }, - ) + fireEvent.click(secondPostButton) - describe('when on /posts/$postId and navigating to ../ with default `from` /posts', () => { - async function runTest(navigateVia: 'Route' | 'RouteApi') { - const rootRoute = createRootRoute() + expect( + await screen.findByTestId('post-detail-index-heading'), + ).toBeInTheDocument() + expect(window.location.pathname).toEqual(`/posts/2${tail}`) + }, +) - const IndexComponent = () => { - const navigate = useNavigate() - return ( - <> -

Index

- - - - ) - } +describe('when on /posts/$postId and navigating to ../ with default `from` /posts', () => { + async function runTest(navigateVia: 'Route' | 'RouteApi') { + const rootRoute = createRootRoute() - const indexRoute = createRoute({ - getParentRoute: () => rootRoute, - path: '/', - component: IndexComponent, - }) + const IndexComponent = () => { + const navigate = useNavigate() + return ( + <> +

Index

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

Layout

- - - ) - }, - }) + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: IndexComponent, + }) - const PostsComponent = () => { - const routeNavigate = postsRoute.useNavigate() - const routeApiNavigate = getRouteApi('/_layout/posts').useNavigate() + const layoutRoute = createRoute({ + getParentRoute: () => rootRoute, + id: '_layout', + component: () => { return ( <> -

Posts

- +

Layout

) - } + }, + }) - const postsRoute = createRoute({ - getParentRoute: () => layoutRoute, - path: 'posts', - component: PostsComponent, - }) + const PostsComponent = () => { + const routeNavigate = postsRoute.useNavigate() + const routeApiNavigate = getRouteApi('/_layout/posts').useNavigate() + return ( + <> +

Posts

+ + + + ) + } - const PostComponent = () => { - const params = useParams({ strict: false }) - return ( - <> - Params: {params.postId} - - - ) - } + const postsRoute = createRoute({ + getParentRoute: () => layoutRoute, + path: 'posts', + component: PostsComponent, + }) - const postRoute = createRoute({ - getParentRoute: () => postsRoute, - path: '$postId', - component: PostComponent, - }) + const PostComponent = () => { + const params = useParams({ strict: false }) + return ( + <> + Params: {params.postId} + + + ) + } - const PostIndexComponent = () => { - return ( - <> -

Post Index

- - ) - } + const postRoute = createRoute({ + getParentRoute: () => postsRoute, + path: '$postId', + component: PostComponent, + }) - const postIndexRoute = createRoute({ - getParentRoute: () => postRoute, - path: '/', - component: PostIndexComponent, - }) + const PostIndexComponent = () => { + return ( + <> +

Post Index

+ + ) + } - const DetailsComponent = () => { - return ( - <> -

Details!

- - ) - } + const postIndexRoute = createRoute({ + getParentRoute: () => postRoute, + path: '/', + component: PostIndexComponent, + }) - const detailsRoute = createRoute({ - getParentRoute: () => postRoute, - path: 'details', - component: DetailsComponent, - }) + const DetailsComponent = () => { + return ( + <> +

Details!

+ + ) + } - const router = createRouter({ - routeTree: rootRoute.addChildren([ - indexRoute, - layoutRoute.addChildren([ - postsRoute.addChildren([ - postRoute.addChildren([postIndexRoute, detailsRoute]), - ]), + const detailsRoute = createRoute({ + getParentRoute: () => postRoute, + path: 'details', + component: DetailsComponent, + }) + + const router = createRouter({ + routeTree: rootRoute.addChildren([ + indexRoute, + layoutRoute.addChildren([ + postsRoute.addChildren([ + postRoute.addChildren([postIndexRoute, detailsRoute]), ]), ]), - }) + ]), + }) - render() + render() - const postsButton = await screen.findByTestId('index-to-first-post-btn') + const postsButton = await screen.findByTestId('index-to-first-post-btn') - fireEvent.click(postsButton) + fireEvent.click(postsButton) - expect(await screen.findByTestId('details-heading')).toBeInTheDocument() + expect(await screen.findByTestId('details-heading')).toBeInTheDocument() - expect(window.location.pathname).toEqual('/posts/id1/details') + expect(window.location.pathname).toEqual('/posts/id1/details') - const homeButton = await screen.findByTestId('btn-to-home') + const homeButton = await screen.findByTestId('btn-to-home') - fireEvent.click(homeButton) + fireEvent.click(homeButton) - expect(await screen.findByTestId('index-heading')).toBeInTheDocument() - expect(window.location.pathname).toEqual('/') - } + expect(await screen.findByTestId('index-heading')).toBeInTheDocument() + expect(window.location.pathname).toEqual('/') + } - test('Route', () => runTest('Route')) - test('RouteApi', () => runTest('RouteApi')) - }) + test('Route', () => runTest('Route')) + test('RouteApi', () => runTest('RouteApi')) }) describe.each([{ basepath: '' }, { basepath: '/basepath' }])( @@ -2361,6 +2356,14 @@ describe.each([{ basepath: '' }, { basepath: '/basepath' }])( > Link to .. from /param/foo/a + ) @@ -2511,6 +2514,26 @@ describe.each([{ basepath: '' }, { basepath: '/basepath' }])( expect(window.location.pathname).toBe(`${basepath}/param/foo`) }) + test('should navigate to a parent link based on active location', async () => { + const router = setupRouter() + + render() + + await act(async () => { + history.push(`${basepath}/param/foo/a/b`) + }) + + const relativeLink = await screen.findByTestId('link-to-previous') + + // Click the link and ensure the new location + await act(async () => { + fireEvent.click(relativeLink) + }) + + expect(window.location.pathname).toBe(`${basepath}/param/foo/a`) + }) + + test('should navigate to same route with different params', async () => { const router = setupRouter() diff --git a/packages/solid-router/tests/link.test.tsx b/packages/solid-router/tests/link.test.tsx index f53c6d46baa..ae751d4386f 100644 --- a/packages/solid-router/tests/link.test.tsx +++ b/packages/solid-router/tests/link.test.tsx @@ -4591,950 +4591,972 @@ describe('search middleware', () => { }) }) -describe('relative links to current route', () => { - test.each([true, false])( - 'should navigate to current route when using "." in nested route structure from Index Route with trailingSlash: %s', - async (trailingSlash: boolean) => { - const tail = trailingSlash ? '/' : '' - +describe.each([{ basepath: '' }, { basepath: '/basepath' }])( + 'relative links with %s', + ({ basepath }) => { + const setupRouter = () => { const rootRoute = createRootRoute() - - const IndexComponent = () => { - return ( - <> - - Post - - - Search - - - Search2 - - - - ) - } - const indexRoute = createRoute({ getParentRoute: () => rootRoute, path: '/', - component: IndexComponent, - validateSearch: z.object({ - param1: z.string().optional(), - }), + component: () => { + return

Index Route

+ }, + }) + const aRoute = createRoute({ + getParentRoute: () => rootRoute, + path: 'a', + component: () => { + return ( + <> +

A Route

+ + + ) + }, }) - const postRoute = createRoute({ - getParentRoute: () => indexRoute, - path: 'post', - component: () => ( - <> -
Post
- - ), + const bRoute = createRoute({ + getParentRoute: () => aRoute, + path: 'b', + component: () => { + return ( + <> +

B Route

+ Link to Parent + + ) + }, }) - const router = createRouter({ - routeTree: rootRoute.addChildren([indexRoute, postRoute]), - history, - trailingSlash: trailingSlash ? 'always' : 'never', + const paramRoute = createRoute({ + getParentRoute: () => rootRoute, + path: 'param/$param', + component: () => { + return ( + <> +

Param Route

+ + Link to ./a + + + Link to c + + + Link to ../c + + + + ) + }, }) - render(() => ) + const paramARoute = createRoute({ + getParentRoute: () => paramRoute, + path: 'a', + component: () => { + return ( + <> +

Param A Route

+ + Link to .. from /param/foo/a + + + Link to .. from current active route + + + + ) + }, + }) - const postButton = await screen.findByTestId('posts-link') - const searchButton = await screen.findByTestId('search-link') - const searchButton2 = await screen.findByTestId('search2-link') + const paramBRoute = createRoute({ + getParentRoute: () => paramARoute, + path: 'b', + component: () => { + return ( + <> +

Param B Route

+ Link to Parent + + Link to . with param:bar + + + Link to Parent with param:bar + + ({ ...prev, param: 'bar' })} + > + Link to Parent with param:bar functional + + + ) + }, + }) - fireEvent.click(postButton) + const paramCRoute = createRoute({ + getParentRoute: () => paramARoute, + path: 'c', + component: () => { + return

Param C Route

+ }, + }) - await waitFor(() => { - expect(window.location.pathname).toBe(`/post${tail}`) + const splatRoute = createRoute({ + getParentRoute: () => rootRoute, + path: 'splat/$', + component: () => { + return ( + <> +

Splat Route

+ + Unsafe link to .. + + + Unsafe link to . + + + Unsafe link to ./child + + + ) + }, }) - fireEvent.click(searchButton) + return createRouter({ + routeTree: rootRoute.addChildren([ + indexRoute, + aRoute.addChildren([bRoute]), + paramRoute.addChildren([ + paramARoute.addChildren([paramBRoute, paramCRoute]), + ]), + splatRoute, + ]), - await waitFor(() => { - expect(router.state.location.pathname).toBe(`/post${tail}`) - expect(router.state.location.search).toEqual({ param1: 'value1' }) + basepath: basepath === '' ? undefined : basepath, }) + } - fireEvent.click(searchButton2) + test('should navigate to the parent route', async () => { + const router = setupRouter() - await waitFor(() => { - expect(router.state.location.pathname).toBe(`/post${tail}`) - expect(router.state.location.search).toEqual({ param1: 'value2' }) - }) - }, - ) + render(() => ) - test.each([true, false])( - 'should navigate to current route with search params when using "." in nested route structure from Index Route with trailingSlash: %s', - async (trailingSlash: boolean) => { - const tail = trailingSlash ? '/' : '' + // Navigate to /a/b + window.history.replaceState(null, 'root', `${basepath}/a/b`) - const rootRoute = createRootRoute() + // Inspect the link to go up a parent + const parentLink = await screen.findByText('Link to Parent') + expect(parentLink.getAttribute('href')).toBe(`${basepath}/a`) - const IndexComponent = () => { - return ( - <> - - Post - - - Search - - - Search2 - - - - ) - } + // Click the link and ensure the new location + fireEvent.click(parentLink) - const indexRoute = createRoute({ - getParentRoute: () => rootRoute, - path: '/', - component: IndexComponent, - validateSearch: z.object({ - param1: z.string().optional(), - }), - }) + await waitFor(() => + expect(window.location.pathname).toBe(`${basepath}/a`), + ) + }) - const postRoute = createRoute({ - getParentRoute: () => indexRoute, - path: 'post', - component: () =>
Post
, - }) + test('should navigate to the parent route and keep params', async () => { + const router = setupRouter() - const router = createRouter({ - routeTree: rootRoute.addChildren([indexRoute, postRoute]), - history, - trailingSlash: trailingSlash ? 'always' : 'never', - }) + render(() => ) + + // Navigate to /param/oldParamValue/a/b + window.history.replaceState(null, 'root', `${basepath}/param/foo/a/b`) + + // Inspect the link to go up a parent and keep the params + const parentLink = await screen.findByText('Link to Parent') + expect(parentLink.getAttribute('href')).toBe(`${basepath}/param/foo/a`) + + // Click the link and ensure the new location + fireEvent.click(parentLink) + + await waitFor(() => + expect(window.location.pathname).toBe(`${basepath}/param/foo/a`), + ) + }) + + test('should navigate to the parent route and change params', async () => { + const router = setupRouter() render(() => ) - const postButton = await screen.findByTestId('posts-link') + // Navigate to /param/oldParamValue/a/b + window.history.replaceState(null, 'root', `${basepath}/param/foo/a/b`) - fireEvent.click(postButton) + // Inspect the link to go up a parent and keep the params + const parentLink = await screen.findByText( + 'Link to Parent with param:bar', + ) + expect(parentLink.getAttribute('href')).toBe(`${basepath}/param/bar/a`) - await waitFor(() => { - expect(window.location.pathname).toBe(`/post${tail}`) - }) + // Click the link and ensure the new location + fireEvent.click(parentLink) - const searchButton = await screen.findByTestId('search-link') + await waitFor(() => + expect(window.location.pathname).toBe(`${basepath}/param/bar/a`), + ) + }) - fireEvent.click(searchButton) + test('should navigate to a relative link based on render location', async () => { + const router = setupRouter() - await waitFor(() => { - expect(router.state.location.pathname).toBe(`/post${tail}`) - expect(router.state.location.search).toEqual({ param1: 'value1' }) - }) + render(() => ) - const searchButton2 = await screen.findByTestId('search2-link') + window.history.replaceState(null, 'root', `${basepath}/param/foo/a/b`) - fireEvent.click(searchButton2) + // Inspect the relative link to ./a + const relativeLink = await screen.findByText('Link to ./a') + expect(relativeLink.getAttribute('href')).toBe(`${basepath}/param/foo/a`) - await waitFor(() => { - expect(router.state.location.pathname).toBe(`/post${tail}`) - expect(router.state.location.search).toEqual({ param1: 'value2' }) - }) - }, - ) + // Click the link and ensure the new location + fireEvent.click(relativeLink) - test.each([true, false])( - 'should navigate to current route with changing path params when using "." in nested route structure with trailingSlash: %s', - async (trailingSlash) => { - const tail = trailingSlash ? '/' : '' - const rootRoute = createRootRoute() + await waitFor(() => + expect(window.location.pathname).toBe(`${basepath}/param/foo/a`), + ) + }) - const IndexComponent = () => { - return ( - <> -

Index

- - Posts - - - ) - } + test('should navigate to a parent link based on render location', async () => { + const router = setupRouter() - const indexRoute = createRoute({ - getParentRoute: () => rootRoute, - path: '/', - component: IndexComponent, - }) + render(() => ) - const layoutRoute = createRoute({ - getParentRoute: () => rootRoute, - id: '_layout', - component: () => { - return ( - <> -

Layout

- - - ) - }, - }) + window.history.replaceState(null, 'root', `${basepath}/param/foo/a/b`) - const PostsComponent = () => { - return ( - <> -

Posts

- - To first post - - - To second post - - - - ) - } + // Inspect the relative link to ./a + const relativeLink = await screen.findByText( + 'Link to .. from /param/foo/a', + ) + expect(relativeLink.getAttribute('href')).toBe(`${basepath}/param/foo`) - const postsRoute = createRoute({ - getParentRoute: () => layoutRoute, - path: 'posts', - component: PostsComponent, - }) + // Click the link and ensure the new location + fireEvent.click(relativeLink) - const PostComponent = () => { - const params = useParams({ strict: false }) - return ( - <> - - Params: {params().postId} - - - ) - } + await waitFor(() => + expect(window.location.pathname).toBe(`${basepath}/param/foo`), + ) + }) - const postRoute = createRoute({ - getParentRoute: () => postsRoute, - path: '$postId', - component: PostComponent, - }) + test('should navigate to a parent link based on active location', async () => { + const router = setupRouter() - const router = createRouter({ - routeTree: rootRoute.addChildren([ - indexRoute, - layoutRoute.addChildren([postsRoute.addChildren([postRoute])]), - ]), - trailingSlash: trailingSlash ? 'always' : 'never', - }) + render(() => ) + + window.history.replaceState(null, 'root', `${basepath}/param/foo/a/b`) + + const relativeLink = await screen.findByTestId('link-to-previous') + + expect(relativeLink.getAttribute('href')).toBe(`${basepath}/param/foo/a`) + + // Click the link and ensure the new location + fireEvent.click(relativeLink) + + await waitFor(() => + expect(window.location.pathname).toBe(`${basepath}/param/foo/a`), + ) + }) + + test('should navigate to a child link based on pathname', async () => { + const router = setupRouter() + + render(() => ) + + window.history.replaceState(null, 'root', `${basepath}/param/foo/a/b`) + + // Inspect the relative link to ./a + const relativeLink = await screen.findByText('Link to c') + expect(relativeLink.getAttribute('href')).toBe( + `${basepath}/param/foo/a/b/c`, + ) + + // Click the link and ensure the new location + fireEvent.click(relativeLink) + + await waitFor(() => + expect(window.location.pathname).toBe(`${basepath}/param/foo/a/b/c`), + ) + }) + + test('should navigate to a relative link based on pathname', async () => { + const router = setupRouter() render(() => ) - const postsButton = await screen.findByTestId('posts-link') + window.history.replaceState(null, 'root', `${basepath}/param/foo/a/b`) - fireEvent.click(postsButton) + // Inspect the relative link to ./a + const relativeLink = await screen.findByText('Link to ../c') + expect(relativeLink.getAttribute('href')).toBe( + `${basepath}/param/foo/a/c`, + ) - await waitFor(() => { - expect(window.location.pathname).toEqual(`/posts${tail}`) - }) + // Click the link and ensure the new location + fireEvent.click(relativeLink) + await waitFor(() => + expect(window.location.pathname).toBe(`${basepath}/param/foo/a/c`), + ) + }) - const firstPostButton = await screen.findByTestId('first-post-link') + test('should navigate to parent inside of splat route based on pathname', async () => { + const router = setupRouter() - fireEvent.click(firstPostButton) + render(() => ) - await waitFor(() => { - expect(window.location.pathname).toEqual(`/posts/id1${tail}`) - }) + window.history.replaceState(null, 'root', `${basepath}/splat/a/b/c/d`) - const secondPostButton = await screen.findByTestId('second-post-link') + const relativeLink = await screen.findByText('Unsafe link to ..') + expect(relativeLink.getAttribute('href')).toBe(`${basepath}/splat/a/b/c`) - fireEvent.click(secondPostButton) + // Click the link and ensure the new location + fireEvent.click(relativeLink) + await waitFor(() => + expect(window.location.pathname).toBe(`${basepath}/splat/a/b/c`), + ) + }) - await waitFor(() => { - expect(window.location.pathname).toEqual(`/posts/id2${tail}`) - }) - }, - ) -}) + test('should navigate to same route inside of splat route based on pathname', async () => { + const router = setupRouter() -describe('relative links to from route', () => { - test.each([true, false])( - 'should navigate to from route when using "." in nested route structure from Index Route with trailingSlash: %s', - async (trailingSlash: boolean) => { - const tail = trailingSlash ? '/' : '' + window.history.replaceState(null, 'root', `${basepath}/splat/a/b/c`) - const rootRoute = createRootRoute() + render(() => ) - const IndexComponent = () => { - return ( - <> - - Post - - - Search - - - Go To Home - - - - ) - } + const relativeLink = await screen.findByText('Unsafe link to .') + expect(relativeLink.getAttribute('href')).toBe(`${basepath}/splat/a/b/c`) - const indexRoute = createRoute({ - getParentRoute: () => rootRoute, - path: '/', - component: IndexComponent, - validateSearch: z.object({ - param1: z.string().optional(), - }), - }) + // Click the link and ensure the new location + fireEvent.click(relativeLink) - const postRoute = createRoute({ - getParentRoute: () => indexRoute, - path: 'post', - component: () =>
Post
, - }) + expect(window.location.pathname).toBe(`${basepath}/splat/a/b/c`) + }) - const router = createRouter({ - routeTree: rootRoute.addChildren([indexRoute, postRoute]), - history, - trailingSlash: trailingSlash ? 'always' : 'never', - }) + test('should navigate to child route inside of splat route based on pathname', async () => { + const router = setupRouter() - render(() => ) + window.history.replaceState(null, 'root', `${basepath}/splat/a/b/c`) - const postButton = await screen.findByTestId('posts-link') + render(() => ) - fireEvent.click(postButton) + const relativeLink = await screen.findByText('Unsafe link to ./child') + expect(relativeLink.getAttribute('href')).toBe( + `${basepath}/splat/a/b/c/child`, + ) - await waitFor(() => { - expect(router.state.location.pathname).toBe(`/post${tail}`) - }) + // Click the link and ensure the new location + fireEvent.click(relativeLink) + await waitFor(() => + expect(window.location.pathname).toBe(`${basepath}/splat/a/b/c/child`), + ) + }) - const searchButton = await screen.findByTestId('search-link') + test('should navigate to same route with different params', async () => { + const router = setupRouter() - fireEvent.click(searchButton) + window.history.replaceState(null, 'root', `${basepath}/param/foo/a/b`) - await waitFor(() => { - expect(router.state.location.pathname).toBe(`/post${tail}`) - expect(router.state.location.search).toEqual({ param1: 'value1' }) - }) + render(() => ) - const homeBtn = await screen.findByTestId('home-link') + const parentLink = await screen.findByText('Link to . with param:bar') - fireEvent.click(homeBtn) + fireEvent.click(parentLink) - await waitFor(() => { - expect(router.state.location.pathname).toBe(`/`) - expect(router.state.location.search).toEqual({}) - }) - }, - ) + await waitFor(() => + expect(window.location.pathname).toBe(`${basepath}/param/bar/a/b`), + ) + }) + }, +) +describe('relative links to current route', () => { test.each([true, false])( - 'should navigate to from route with path params when using "." in nested route structure with trailingSlash: %s', - async (trailingSlash) => { + 'should navigate to current route when using "." in nested route structure from Index Route with trailingSlash: %s', + async (trailingSlash: boolean) => { const tail = trailingSlash ? '/' : '' + const rootRoute = createRootRoute() const IndexComponent = () => { return ( <> -

Index

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

Layout

- - - ) - }, - }) - - const PostsComponent = () => { - return ( - <> -

Posts

- - To first post + + Post - To second post - - - To posts list - - - - ) - } - - const PostDetailComponent = () => { - return ( - <> -

Post Detail

- - To post info - - - To post notes + Search - To index detail options + Search2 ) } - 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 indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: IndexComponent, + validateSearch: z.object({ + param1: z.string().optional(), + }), }) - const postNotesRoute = createRoute({ - getParentRoute: () => postDetailRoute, - path: 'notes', - component: PostNotesComponent, + const postRoute = createRoute({ + getParentRoute: () => indexRoute, + path: 'post', + component: () => ( + <> +
Post
+ + ), }) const router = createRouter({ - routeTree: rootRoute.addChildren([ - indexRoute, - layoutRoute.addChildren([ - postsRoute.addChildren([ - postDetailRoute.addChildren([postInfoRoute, postNotesRoute]), - ]), - ]), - ]), + routeTree: rootRoute.addChildren([indexRoute, postRoute]), + history, trailingSlash: trailingSlash ? 'always' : 'never', }) render(() => ) - const postsButton = await screen.findByTestId('posts-link') + const postButton = await screen.findByTestId('posts-link') + const searchButton = await screen.findByTestId('search-link') + const searchButton2 = await screen.findByTestId('search2-link') - fireEvent.click(postsButton) + fireEvent.click(postButton) await waitFor(() => { - expect(screen.queryByTestId('posts-index-heading')).toBeInTheDocument() - expect(window.location.pathname).toEqual(`/posts${tail}`) + expect(window.location.pathname).toBe(`/post${tail}`) }) - const firstPostButton = await screen.findByTestId('first-post-link') - - fireEvent.click(firstPostButton) + fireEvent.click(searchButton) await waitFor(() => { - expect( - screen.queryByTestId('post-detail-index-heading'), - ).toBeInTheDocument() - expect(window.location.pathname).toEqual(`/posts/1${tail}`) + expect(router.state.location.pathname).toBe(`/post${tail}`) + expect(router.state.location.search).toEqual({ param1: 'value1' }) }) - const postInfoButton = await screen.findByTestId('post-info-link') - - fireEvent.click(postInfoButton) + fireEvent.click(searchButton2) await waitFor(() => { - expect(screen.queryByTestId('post-info-heading')).toBeInTheDocument() - expect(window.location.pathname).toEqual(`/posts/1/info${tail}`) + expect(router.state.location.pathname).toBe(`/post${tail}`) + expect(router.state.location.search).toEqual({ param1: 'value2' }) }) + }, + ) - const toPostDetailIndexButton = await screen.findByTestId( - 'to-post-detail-index-link', - ) + test.each([true, false])( + 'should navigate to current route with search params when using "." in nested route structure from Index Route with trailingSlash: %s', + async (trailingSlash: boolean) => { + const tail = trailingSlash ? '/' : '' - fireEvent.click(toPostDetailIndexButton) + const rootRoute = createRootRoute() - await waitFor(() => { - expect( - screen.queryByTestId('post-detail-index-heading'), - ).toBeInTheDocument() - expect( - screen.queryByTestId('post-info-heading'), - ).not.toBeInTheDocument() - expect(window.location.pathname).toEqual(`/posts/1${tail}`) + const IndexComponent = () => { + return ( + <> + + Post + + + Search + + + Search2 + + + + ) + } + + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: IndexComponent, + validateSearch: z.object({ + param1: z.string().optional(), + }), }) - const postNotesButton = await screen.findByTestId('post-notes-link') + const postRoute = createRoute({ + getParentRoute: () => indexRoute, + path: 'post', + component: () =>
Post
, + }) - fireEvent.click(postNotesButton) + const router = createRouter({ + routeTree: rootRoute.addChildren([indexRoute, postRoute]), + history, + trailingSlash: trailingSlash ? 'always' : 'never', + }) + + render(() => ) + + const postButton = await screen.findByTestId('posts-link') + + fireEvent.click(postButton) await waitFor(() => { - expect(screen.queryByTestId('post-notes-heading')).toBeInTheDocument() - expect(window.location.pathname).toEqual(`/posts/1/notes${tail}`) + expect(window.location.pathname).toBe(`/post${tail}`) }) - const toPostsIndexButton = await screen.findByTestId( - 'to-posts-index-link', - ) + const searchButton = await screen.findByTestId('search-link') - fireEvent.click(toPostsIndexButton) + fireEvent.click(searchButton) await waitFor(() => { - expect(screen.queryByTestId('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}`) + expect(router.state.location.pathname).toBe(`/post${tail}`) + expect(router.state.location.search).toEqual({ param1: 'value1' }) }) - const secondPostButton = await screen.findByTestId('second-post-link') + const searchButton2 = await screen.findByTestId('search2-link') - fireEvent.click(secondPostButton) + fireEvent.click(searchButton2) await waitFor(() => { - expect( - screen.queryByTestId('post-detail-index-heading'), - ).toBeInTheDocument() - expect(window.location.pathname).toEqual(`/posts/2${tail}`) + expect(router.state.location.pathname).toBe(`/post${tail}`) + expect(router.state.location.search).toEqual({ param1: 'value2' }) }) }, ) -}) -describe.each([{ basepath: '' }, { basepath: '/basepath' }])( - 'relative links with %s', - ({ basepath }) => { - const setupRouter = () => { + test.each([true, false])( + 'should navigate to current route with changing path params when using "." in nested route structure with trailingSlash: %s', + async (trailingSlash) => { + const tail = trailingSlash ? '/' : '' const rootRoute = createRootRoute() - const indexRoute = createRoute({ - getParentRoute: () => rootRoute, - path: '/', - component: () => { - return

Index Route

- }, - }) - const aRoute = createRoute({ - getParentRoute: () => rootRoute, - path: 'a', - component: () => { - return ( - <> -

A Route

- - - ) - }, - }) - - const bRoute = createRoute({ - getParentRoute: () => aRoute, - path: 'b', - component: () => { - return ( - <> -

B Route

- Link to Parent - - ) - }, - }) - - const paramRoute = createRoute({ - getParentRoute: () => rootRoute, - path: 'param/$param', - component: () => { - return ( - <> -

Param Route

- - Link to ./a - - - Link to c - - - Link to ../c - - - - ) - }, - }) - - const paramARoute = createRoute({ - getParentRoute: () => paramRoute, - path: 'a', - component: () => { - return ( - <> -

Param A Route

- - Link to .. from /param/foo/a - - - - ) - }, - }) - const paramBRoute = createRoute({ - getParentRoute: () => paramARoute, - path: 'b', - component: () => { - return ( - <> -

Param B Route

- Link to Parent - - Link to . with param:bar - - - Link to Parent with param:bar - - ({ ...prev, param: 'bar' })} - > - Link to Parent with param:bar functional - - - ) - }, - }) + const IndexComponent = () => { + return ( + <> +

Index

+ + Posts + + + ) + } - const paramCRoute = createRoute({ - getParentRoute: () => paramARoute, - path: 'c', - component: () => { - return

Param C Route

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

Splat Route

- - Unsafe link to .. - - - Unsafe link to . - - - Unsafe link to ./child - +

Layout

+ ) }, }) - return createRouter({ + const PostsComponent = () => { + return ( + <> +

Posts

+ + To first post + + + To second post + + + + ) + } + + const postsRoute = createRoute({ + getParentRoute: () => layoutRoute, + path: 'posts', + component: PostsComponent, + }) + + const PostComponent = () => { + const params = useParams({ strict: false }) + return ( + <> + + Params: {params().postId} + + + ) + } + + const postRoute = createRoute({ + getParentRoute: () => postsRoute, + path: '$postId', + component: PostComponent, + }) + + const router = createRouter({ routeTree: rootRoute.addChildren([ indexRoute, - aRoute.addChildren([bRoute]), - paramRoute.addChildren([ - paramARoute.addChildren([paramBRoute, paramCRoute]), - ]), - splatRoute, + layoutRoute.addChildren([postsRoute.addChildren([postRoute])]), ]), - - basepath: basepath === '' ? undefined : basepath, + trailingSlash: trailingSlash ? 'always' : 'never', }) - } - - test('should navigate to the parent route', async () => { - const router = setupRouter() render(() => ) - // Navigate to /a/b - window.history.replaceState(null, 'root', `${basepath}/a/b`) + const postsButton = await screen.findByTestId('posts-link') - // Inspect the link to go up a parent - const parentLink = await screen.findByText('Link to Parent') - expect(parentLink.getAttribute('href')).toBe(`${basepath}/a`) + fireEvent.click(postsButton) - // Click the link and ensure the new location - fireEvent.click(parentLink) + await waitFor(() => { + expect(window.location.pathname).toEqual(`/posts${tail}`) + }) - await waitFor(() => - expect(window.location.pathname).toBe(`${basepath}/a`), - ) - }) + const firstPostButton = await screen.findByTestId('first-post-link') - test('should navigate to the parent route and keep params', async () => { - const router = setupRouter() + fireEvent.click(firstPostButton) - render(() => ) + await waitFor(() => { + expect(window.location.pathname).toEqual(`/posts/id1${tail}`) + }) - // Navigate to /param/oldParamValue/a/b - window.history.replaceState(null, 'root', `${basepath}/param/foo/a/b`) + const secondPostButton = await screen.findByTestId('second-post-link') - // Inspect the link to go up a parent and keep the params - const parentLink = await screen.findByText('Link to Parent') - expect(parentLink.getAttribute('href')).toBe(`${basepath}/param/foo/a`) + fireEvent.click(secondPostButton) - // Click the link and ensure the new location - fireEvent.click(parentLink) + await waitFor(() => { + expect(window.location.pathname).toEqual(`/posts/id2${tail}`) + }) + }, + ) +}) - await waitFor(() => - expect(window.location.pathname).toBe(`${basepath}/param/foo/a`), - ) - }) +describe('relative links to from route', () => { + test.each([true, false])( + 'should navigate to from route when using "." in nested route structure from Index Route with trailingSlash: %s', + async (trailingSlash: boolean) => { + const tail = trailingSlash ? '/' : '' - test('should navigate to the parent route and change params', async () => { - const router = setupRouter() + const rootRoute = createRootRoute() - render(() => ) + const IndexComponent = () => { + return ( + <> + + Post + + + Search + + + Go To Home + + + + ) + } - // Navigate to /param/oldParamValue/a/b - window.history.replaceState(null, 'root', `${basepath}/param/foo/a/b`) + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: IndexComponent, + validateSearch: z.object({ + param1: z.string().optional(), + }), + }) - // Inspect the link to go up a parent and keep the params - const parentLink = await screen.findByText( - 'Link to Parent with param:bar', - ) - expect(parentLink.getAttribute('href')).toBe(`${basepath}/param/bar/a`) + const postRoute = createRoute({ + getParentRoute: () => indexRoute, + path: 'post', + component: () =>
Post
, + }) - // Click the link and ensure the new location - fireEvent.click(parentLink) + const router = createRouter({ + routeTree: rootRoute.addChildren([indexRoute, postRoute]), + history, + trailingSlash: trailingSlash ? 'always' : 'never', + }) - await waitFor(() => - expect(window.location.pathname).toBe(`${basepath}/param/bar/a`), - ) - }) + render(() => ) - test('should navigate to a relative link based on render location', async () => { - const router = setupRouter() + const postButton = await screen.findByTestId('posts-link') - render(() => ) + fireEvent.click(postButton) - window.history.replaceState(null, 'root', `${basepath}/param/foo/a/b`) + await waitFor(() => { + expect(router.state.location.pathname).toBe(`/post${tail}`) + }) - // Inspect the relative link to ./a - const relativeLink = await screen.findByText('Link to ./a') - expect(relativeLink.getAttribute('href')).toBe(`${basepath}/param/foo/a`) + const searchButton = await screen.findByTestId('search-link') - // Click the link and ensure the new location - fireEvent.click(relativeLink) + fireEvent.click(searchButton) - await waitFor(() => - expect(window.location.pathname).toBe(`${basepath}/param/foo/a`), - ) - }) + await waitFor(() => { + expect(router.state.location.pathname).toBe(`/post${tail}`) + expect(router.state.location.search).toEqual({ param1: 'value1' }) + }) - test('should navigate to a parent link based on render location', async () => { - const router = setupRouter() + const homeBtn = await screen.findByTestId('home-link') - render(() => ) + fireEvent.click(homeBtn) - window.history.replaceState(null, 'root', `${basepath}/param/foo/a/b`) + await waitFor(() => { + expect(router.state.location.pathname).toBe(`/`) + expect(router.state.location.search).toEqual({}) + }) + }, + ) - // Inspect the relative link to ./a - const relativeLink = await screen.findByText( - 'Link to .. from /param/foo/a', - ) - expect(relativeLink.getAttribute('href')).toBe(`${basepath}/param/foo`) + test.each([true, false])( + 'should navigate to from route with path params when using "." in nested route structure with trailingSlash: %s', + async (trailingSlash) => { + const tail = trailingSlash ? '/' : '' + const rootRoute = createRootRoute() - // Click the link and ensure the new location - fireEvent.click(relativeLink) + const IndexComponent = () => { + return ( + <> +

Index

+ + Posts + + + ) + } - await waitFor(() => - expect(window.location.pathname).toBe(`${basepath}/param/foo`), - ) - }) + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: IndexComponent, + }) - test('should navigate to a child link based on pathname', async () => { - const router = setupRouter() + const layoutRoute = createRoute({ + getParentRoute: () => rootRoute, + id: '_layout', + component: () => { + return ( + <> +

Layout

+ + + ) + }, + }) - render(() => ) + const PostsComponent = () => { + return ( + <> +

Posts

+ + To first post + + + To second post + + + To posts list + + + + ) + } - window.history.replaceState(null, 'root', `${basepath}/param/foo/a/b`) + const PostDetailComponent = () => { + return ( + <> +

Post Detail

+ + To post info + + + To post notes + + + To index detail options + + + + ) + } - // Inspect the relative link to ./a - const relativeLink = await screen.findByText('Link to c') - expect(relativeLink.getAttribute('href')).toBe( - `${basepath}/param/foo/a/b/c`, - ) + const PostInfoComponent = () => { + return ( + <> +

Post Info

+ + ) + } - // Click the link and ensure the new location - fireEvent.click(relativeLink) + const PostNotesComponent = () => { + return ( + <> +

Post Notes

+ + ) + } - await waitFor(() => - expect(window.location.pathname).toBe(`${basepath}/param/foo/a/b/c`), - ) - }) + const postsRoute = createRoute({ + getParentRoute: () => layoutRoute, + path: 'posts', + component: PostsComponent, + }) - test('should navigate to a relative link based on pathname', async () => { - const router = setupRouter() + const postDetailRoute = createRoute({ + getParentRoute: () => postsRoute, + path: '$postId', + component: PostDetailComponent, + }) - render(() => ) + const postInfoRoute = createRoute({ + getParentRoute: () => postDetailRoute, + path: 'info', + component: PostInfoComponent, + }) - window.history.replaceState(null, 'root', `${basepath}/param/foo/a/b`) + const postNotesRoute = createRoute({ + getParentRoute: () => postDetailRoute, + path: 'notes', + component: PostNotesComponent, + }) - // Inspect the relative link to ./a - const relativeLink = await screen.findByText('Link to ../c') - expect(relativeLink.getAttribute('href')).toBe( - `${basepath}/param/foo/a/c`, - ) + const router = createRouter({ + routeTree: rootRoute.addChildren([ + indexRoute, + layoutRoute.addChildren([ + postsRoute.addChildren([ + postDetailRoute.addChildren([postInfoRoute, postNotesRoute]), + ]), + ]), + ]), + trailingSlash: trailingSlash ? 'always' : 'never', + }) - // Click the link and ensure the new location - fireEvent.click(relativeLink) - await waitFor(() => - expect(window.location.pathname).toBe(`${basepath}/param/foo/a/c`), - ) - }) + render(() => ) - test('should navigate to parent inside of splat route based on pathname', async () => { - const router = setupRouter() + const postsButton = await screen.findByTestId('posts-link') - render(() => ) + fireEvent.click(postsButton) - window.history.replaceState(null, 'root', `${basepath}/splat/a/b/c/d`) + await waitFor(() => { + expect(screen.queryByTestId('posts-index-heading')).toBeInTheDocument() + expect(window.location.pathname).toEqual(`/posts${tail}`) + }) - const relativeLink = await screen.findByText('Unsafe link to ..') - expect(relativeLink.getAttribute('href')).toBe(`${basepath}/splat/a/b/c`) + const firstPostButton = await screen.findByTestId('first-post-link') - // Click the link and ensure the new location - fireEvent.click(relativeLink) - await waitFor(() => - expect(window.location.pathname).toBe(`${basepath}/splat/a/b/c`), - ) - }) + fireEvent.click(firstPostButton) - test('should navigate to same route inside of splat route based on pathname', async () => { - const router = setupRouter() + await waitFor(() => { + expect( + screen.queryByTestId('post-detail-index-heading'), + ).toBeInTheDocument() + expect(window.location.pathname).toEqual(`/posts/1${tail}`) + }) - window.history.replaceState(null, 'root', `${basepath}/splat/a/b/c`) + const postInfoButton = await screen.findByTestId('post-info-link') - render(() => ) + fireEvent.click(postInfoButton) - const relativeLink = await screen.findByText('Unsafe link to .') - expect(relativeLink.getAttribute('href')).toBe(`${basepath}/splat/a/b/c`) + await waitFor(() => { + expect(screen.queryByTestId('post-info-heading')).toBeInTheDocument() + expect(window.location.pathname).toEqual(`/posts/1/info${tail}`) + }) - // Click the link and ensure the new location - fireEvent.click(relativeLink) + const toPostDetailIndexButton = await screen.findByTestId( + 'to-post-detail-index-link', + ) - expect(window.location.pathname).toBe(`${basepath}/splat/a/b/c`) - }) + fireEvent.click(toPostDetailIndexButton) - test('should navigate to child route inside of splat route based on pathname', async () => { - const router = setupRouter() + await waitFor(() => { + expect( + screen.queryByTestId('post-detail-index-heading'), + ).toBeInTheDocument() + expect( + screen.queryByTestId('post-info-heading'), + ).not.toBeInTheDocument() + expect(window.location.pathname).toEqual(`/posts/1${tail}`) + }) - window.history.replaceState(null, 'root', `${basepath}/splat/a/b/c`) + const postNotesButton = await screen.findByTestId('post-notes-link') - render(() => ) + fireEvent.click(postNotesButton) - const relativeLink = await screen.findByText('Unsafe link to ./child') - expect(relativeLink.getAttribute('href')).toBe( - `${basepath}/splat/a/b/c/child`, - ) + await waitFor(() => { + expect(screen.queryByTestId('post-notes-heading')).toBeInTheDocument() + expect(window.location.pathname).toEqual(`/posts/1/notes${tail}`) + }) - // Click the link and ensure the new location - fireEvent.click(relativeLink) - await waitFor(() => - expect(window.location.pathname).toBe(`${basepath}/splat/a/b/c/child`), + const toPostsIndexButton = await screen.findByTestId( + 'to-posts-index-link', ) - }) - - test('should navigate to same route with different params', async () => { - const router = setupRouter() - window.history.replaceState(null, 'root', `${basepath}/param/foo/a/b`) + fireEvent.click(toPostsIndexButton) - render(() => ) + await waitFor(() => { + expect(screen.queryByTestId('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 parentLink = await screen.findByText('Link to . with param:bar') + const secondPostButton = await screen.findByTestId('second-post-link') - fireEvent.click(parentLink) + fireEvent.click(secondPostButton) - await waitFor(() => - expect(window.location.pathname).toBe(`${basepath}/param/bar/a/b`), - ) - }) - }, -) + await waitFor(() => { + expect( + screen.queryByTestId('post-detail-index-heading'), + ).toBeInTheDocument() + expect(window.location.pathname).toEqual(`/posts/2${tail}`) + }) + }, + ) +}) diff --git a/packages/solid-router/tests/useNavigate.test.tsx b/packages/solid-router/tests/useNavigate.test.tsx index 5d22b0adb76..d4d15eed1a6 100644 --- a/packages/solid-router/tests/useNavigate.test.tsx +++ b/packages/solid-router/tests/useNavigate.test.tsx @@ -1320,301 +1320,711 @@ test('when setting search params with 2 parallel navigate calls', async () => { expect(search.get('param2')).toEqual('bar') }) -describe('relative navigate to current route', () => { - test.each([true, false])( - 'should navigate to current route with search params when using "." in nested route structure from Index Route with trailingSlash: %s', - async (trailingSlash: boolean) => { - const tail = trailingSlash ? '/' : '' +describe('when on /posts/$postId and navigating to ../ with default `from` /posts', () => { + async function runTest(navigateVia: 'Route' | 'RouteApi') { + const rootRoute = createRootRoute() - const rootRoute = createRootRoute() + const IndexComponent = () => { + const navigate = useNavigate() + return ( + <> +

Index

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

Layout

) - } + }, + }) - const indexRoute = createRoute({ - getParentRoute: () => rootRoute, - path: '/', - component: IndexComponent, - validateSearch: z.object({ - param1: z.string().optional(), - }), - }) + const PostsComponent = () => { + const routeNavigate = postsRoute.useNavigate() + const routeApiNavigate = getRouteApi('/_layout/posts').useNavigate() + return ( + <> +

Posts

+ + + + ) + } - const postRoute = createRoute({ - getParentRoute: () => indexRoute, - path: 'post', - component: () =>
Post
, - }) + const postsRoute = createRoute({ + getParentRoute: () => layoutRoute, + path: 'posts', + component: PostsComponent, + }) - const router = createRouter({ - routeTree: rootRoute.addChildren([indexRoute, postRoute]), - history, - trailingSlash: trailingSlash ? 'always' : 'never', - }) + const PostComponent = () => { + const params = useParams({ strict: false }) + return ( + <> + Params: {params().postId} + + + ) + } - render(() => ) + const postRoute = createRoute({ + getParentRoute: () => postsRoute, + path: '$postId', + component: PostComponent, + }) - const postButton = await screen.findByTestId('posts-btn') + const PostIndexComponent = () => { + return ( + <> +

Post Index

+ + ) + } - fireEvent.click(postButton) + const postIndexRoute = createRoute({ + getParentRoute: () => postRoute, + path: '/', + component: PostIndexComponent, + }) - await waitFor(() => { - expect(router.state.location.pathname).toBe(`/post${tail}`) - }) + const DetailsComponent = () => { + return ( + <> +

Details!

+ + ) + } - const searchButton = await screen.findByTestId('search-btn') + const detailsRoute = createRoute({ + getParentRoute: () => postRoute, + path: 'details', + component: DetailsComponent, + }) - fireEvent.click(searchButton) + const router = createRouter({ + routeTree: rootRoute.addChildren([ + indexRoute, + layoutRoute.addChildren([ + postsRoute.addChildren([ + postRoute.addChildren([postIndexRoute, detailsRoute]), + ]), + ]), + ]), + }) - await waitFor(() => { - expect(router.state.location.pathname).toBe(`/post${tail}`) - expect(router.state.location.search).toEqual({ param1: 'value1' }) - }) + render(() => ) - const searchButton2 = await screen.findByTestId('search2-btn') + const postsButton = await screen.findByTestId('index-to-first-post-btn') - fireEvent.click(searchButton2) + fireEvent.click(postsButton) - await waitFor(() => { - expect(router.state.location.pathname).toBe(`/post${tail}`) - expect(router.state.location.search).toEqual({ param1: 'value2' }) - }) - }, - ) + expect(await screen.findByTestId('details-heading')).toBeInTheDocument() - test.each([true, false])( - 'should navigate to current route with changing path params when using "." in nested route structure with trailingSlash: %s', - async (trailingSlash) => { - const tail = trailingSlash ? '/' : '' - const rootRoute = createRootRoute() + expect(window.location.pathname).toEqual('/posts/id1/details') - const IndexComponent = () => { - const navigate = useNavigate() - return ( - <> -

Index

- - - ) - } + const homeButton = await screen.findByTestId('btn-to-home') + + fireEvent.click(homeButton) + + expect(await screen.findByTestId('index-heading')).toBeInTheDocument() + expect(window.location.pathname).toEqual('/') + } + test('Route', () => runTest('Route')) + test('RouteApi', () => runTest('RouteApi')) +}) + +describe.each([{ basepath: '' }, { basepath: '/basepath' }])( + 'relative useNavigate with %s', + ({ basepath }) => { + const setupRouter = () => { + const rootRoute = createRootRoute() const indexRoute = createRoute({ getParentRoute: () => rootRoute, path: '/', - component: IndexComponent, + component: () => { + return

Index Route

+ }, }) - - const layoutRoute = createRoute({ + const aRoute = createRoute({ getParentRoute: () => rootRoute, - id: '_layout', + path: 'a', component: () => { return ( <> -

Layout

+

A Route

) }, }) - const PostsComponent = () => { - const navigate = useNavigate() - - return ( - <> -

Posts

- - - - - ) - } - - const postsRoute = createRoute({ - getParentRoute: () => layoutRoute, - path: 'posts', - component: PostsComponent, + const bRoute = createRoute({ + getParentRoute: () => aRoute, + path: 'b', + component: function BRoute() { + const navigate = useNavigate() + return ( + <> +

B Route

+ + + ) + }, }) - const PostComponent = () => { - const params = useParams({ strict: false }) - return ( - <> - - Params: {params().postId} - - - ) - } - - const postRoute = createRoute({ - getParentRoute: () => postsRoute, - path: '$postId', - component: PostComponent, + const paramRoute = createRoute({ + getParentRoute: () => rootRoute, + path: 'param/$param', + component: function ParamRoute() { + const navigate = useNavigate() + return ( + <> +

Param Route

+ + + + + ) + }, }) - const router = createRouter({ + const paramARoute = createRoute({ + getParentRoute: () => paramRoute, + path: 'a', + component: function ParamARoute() { + const navigate = useNavigate() + return ( + <> +

Param A Route

+ + + + + ) + }, + }) + + const paramBRoute = createRoute({ + getParentRoute: () => paramARoute, + path: 'b', + component: function ParamBRoute() { + const navigate = useNavigate() + return ( + <> +

Param B Route

+ + + + + ) + }, + }) + + return createRouter({ routeTree: rootRoute.addChildren([ indexRoute, - layoutRoute.addChildren([postsRoute.addChildren([postRoute])]), + aRoute.addChildren([bRoute]), + paramRoute.addChildren([paramARoute, paramBRoute]), ]), - trailingSlash: trailingSlash ? 'always' : 'never', + + basepath: basepath === '' ? undefined : basepath, }) + } + + test('should navigate to the parent route', async () => { + const router = setupRouter() + + // Navigate to /a/b + window.history.replaceState(null, 'root', `${basepath}/a/b`) render(() => ) - const postsButton = await screen.findByTestId('posts-btn') + // Inspect the link to go up a parent + const parentLink = await screen.findByText('Link to Parent') - fireEvent.click(postsButton) + // Click the link and ensure the new location + fireEvent.click(parentLink) + await waitFor(() => + expect(window.location.pathname).toBe(`${basepath}/a`), + ) + }) - await waitFor(() => { - expect(screen.queryByTestId('posts-index-heading')).toBeInTheDocument() - expect(window.location.pathname).toEqual(`/posts${tail}`) - }) + test('should navigate to the parent route and keep params', async () => { + const router = setupRouter() - const firstPostButton = await screen.findByTestId('first-post-btn') + // Navigate to /param/oldParamValue/a/b + window.history.replaceState(null, 'root', `${basepath}/param/foo/a/b`) - fireEvent.click(firstPostButton) + render(() => ) - await waitFor(() => { - expect(screen.queryByTestId('post-id1')).toBeInTheDocument() - expect(window.location.pathname).toEqual(`/posts/id1${tail}`) - }) + // Inspect the link to go up a parent and keep the params + const parentLink = await screen.findByText('Link to Parent') - const secondPostButton = await screen.findByTestId('second-post-btn') + // Click the link and ensure the new location + fireEvent.click(parentLink) + await waitFor(() => + expect(window.location.pathname).toBe(`${basepath}/param/foo/a`), + ) + }) - fireEvent.click(secondPostButton) + test('should navigate to the parent route and change params', async () => { + const router = setupRouter() + // Navigate to /param/oldParamValue/a/b - await waitFor(() => { - expect(screen.queryByTestId('post-id2')).toBeInTheDocument() - expect(window.location.pathname).toEqual(`/posts/id2${tail}`) - }) - }, - ) + window.history.replaceState(null, 'root', `${basepath}/param/foo/a/b`) + render(() => ) + + // Inspect the link to go up a parent and keep the params + const parentLink = await screen.findByText( + 'Link to Parent with param:bar', + ) + + // Click the link and ensure the new location + fireEvent.click(parentLink) + await waitFor(() => + expect(window.location.pathname).toBe(`${basepath}/param/bar/a`), + ) + }) + + test('should navigate to a relative link based on render location with basepath', async () => { + const router = setupRouter() + window.history.replaceState(null, 'root', `${basepath}/param/foo/a/b`) + + render(() => ) + + // Inspect the relative link to ./a + const relativeLink = await screen.findByText('Link to ./a') + + // Click the link and ensure the new location + fireEvent.click(relativeLink) + await waitFor(() => + expect(window.location.pathname).toBe(`${basepath}/param/foo/a`), + ) + }) + + test('should navigate to a parent link based on render location', async () => { + const router = setupRouter() + + window.history.replaceState(null, 'root', `${basepath}/param/foo/a/b`) + + render(() => ) + + // Inspect the relative link to ./a + const relativeLink = await screen.findByText( + 'Link to .. from /param/foo/a', + ) + + // Click the link and ensure the new location + fireEvent.click(relativeLink) + await waitFor(() => + expect(window.location.pathname).toBe(`${basepath}/param/foo`), + ) + }) + + test('should navigate to a parent link based on active location', async () => { + const router = setupRouter() + + render(() => ) + + window.history.replaceState(null, 'root', `${basepath}/param/foo/a/b`) + + const relativeLink = await screen.findByTestId('link-to-previous') + + // Click the link and ensure the new location + fireEvent.click(relativeLink) + + await waitFor(() => + expect(window.location.pathname).toBe(`${basepath}/param/foo/a`), + ) + }) + + test('should navigate to same route with different params', async () => { + const router = setupRouter() + + window.history.replaceState(null, 'root', `${basepath}/param/foo/a/b`) + + render(() => ) + + const parentLink = await screen.findByText('Link to . with param:bar') + + fireEvent.click(parentLink) + await waitFor( + () => + expect(window.location.pathname).toBe(`${basepath}/param/bar/a/b`), + {}, + ) + }) + }, +) + +describe('relative navigate to current route', () => { test.each([true, false])( - 'should navigate to current route with search params when using "." in nested route structure from non-Index Route with trailingSlash: %s', - async (trailingSlash) => { + 'should navigate to current route with search params when using "." in nested route structure from Index Route with trailingSlash: %s', + async (trailingSlash: boolean) => { const tail = trailingSlash ? '/' : '' + const rootRoute = createRootRoute() const IndexComponent = () => { const navigate = useNavigate() return ( <> -

Index

- - ) - } - - const indexRoute = createRoute({ - getParentRoute: () => rootRoute, - path: '/', - component: IndexComponent, - }) - - const PostsComponent = () => { - const navigate = postsRoute.useNavigate() - return ( - <> -

Posts

+ ) } - const postsRoute = createRoute({ + const indexRoute = createRoute({ getParentRoute: () => rootRoute, - path: 'posts', - component: PostsComponent, - }) - - const useModal = (name: string) => { - const currentOpen = postRoute.useSearch({ + path: '/', + component: IndexComponent, + validateSearch: z.object({ + param1: z.string().optional(), + }), + }) + + const postRoute = createRoute({ + getParentRoute: () => indexRoute, + path: 'post', + component: () =>
Post
, + }) + + const router = createRouter({ + routeTree: rootRoute.addChildren([indexRoute, postRoute]), + history, + trailingSlash: trailingSlash ? 'always' : 'never', + }) + + render(() => ) + + const postButton = await screen.findByTestId('posts-btn') + + fireEvent.click(postButton) + + await waitFor(() => { + expect(router.state.location.pathname).toBe(`/post${tail}`) + }) + + const searchButton = await screen.findByTestId('search-btn') + + fireEvent.click(searchButton) + + await waitFor(() => { + expect(router.state.location.pathname).toBe(`/post${tail}`) + expect(router.state.location.search).toEqual({ param1: 'value1' }) + }) + + const searchButton2 = await screen.findByTestId('search2-btn') + + fireEvent.click(searchButton2) + + await waitFor(() => { + expect(router.state.location.pathname).toBe(`/post${tail}`) + expect(router.state.location.search).toEqual({ param1: 'value2' }) + }) + }, + ) + + test.each([true, false])( + 'should navigate to current route with changing path params when using "." in nested route structure with trailingSlash: %s', + 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 = useNavigate() + + return ( + <> +

Posts

+ + + + + ) + } + + const postsRoute = createRoute({ + getParentRoute: () => layoutRoute, + path: 'posts', + component: PostsComponent, + }) + + const PostComponent = () => { + const params = useParams({ strict: false }) + return ( + <> + + Params: {params().postId} + + + ) + } + + const postRoute = createRoute({ + getParentRoute: () => postsRoute, + path: '$postId', + component: PostComponent, + }) + + const router = createRouter({ + routeTree: rootRoute.addChildren([ + indexRoute, + layoutRoute.addChildren([postsRoute.addChildren([postRoute])]), + ]), + trailingSlash: trailingSlash ? 'always' : 'never', + }) + + render(() => ) + + const postsButton = await screen.findByTestId('posts-btn') + + fireEvent.click(postsButton) + + await waitFor(() => { + expect(screen.queryByTestId('posts-index-heading')).toBeInTheDocument() + expect(window.location.pathname).toEqual(`/posts${tail}`) + }) + + const firstPostButton = await screen.findByTestId('first-post-btn') + + fireEvent.click(firstPostButton) + + await waitFor(() => { + expect(screen.queryByTestId('post-id1')).toBeInTheDocument() + expect(window.location.pathname).toEqual(`/posts/id1${tail}`) + }) + + const secondPostButton = await screen.findByTestId('second-post-btn') + + fireEvent.click(secondPostButton) + + await waitFor(() => { + expect(screen.queryByTestId('post-id2')).toBeInTheDocument() + expect(window.location.pathname).toEqual(`/posts/id2${tail}`) + }) + }, + ) + + test.each([true, false])( + 'should navigate to current route with search params when using "." in nested route structure from non-Index Route with trailingSlash: %s', + async (trailingSlash) => { + const tail = trailingSlash ? '/' : '' + const rootRoute = createRootRoute() + + const IndexComponent = () => { + const navigate = useNavigate() + return ( + <> +

Index

+ + + ) + } + + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: IndexComponent, + }) + + const PostsComponent = () => { + const navigate = postsRoute.useNavigate() + return ( + <> +

Posts

+ + + + ) + } + + const postsRoute = createRoute({ + getParentRoute: () => rootRoute, + path: 'posts', + component: PostsComponent, + }) + + const useModal = (name: string) => { + const currentOpen = postRoute.useSearch({ select: (search) => search[`_${name}`], }) @@ -2114,389 +2524,4 @@ describe('relative navigate to from route', () => { }) }, ) - - describe('when on /posts/$postId and navigating to ../ with default `from` /posts', () => { - async function runTest(navigateVia: 'Route' | 'RouteApi') { - 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 routeNavigate = postsRoute.useNavigate() - const routeApiNavigate = getRouteApi('/_layout/posts').useNavigate() - return ( - <> -

Posts

- - - - ) - } - - const postsRoute = createRoute({ - getParentRoute: () => layoutRoute, - path: 'posts', - component: PostsComponent, - }) - - const PostComponent = () => { - const params = useParams({ strict: false }) - return ( - <> - Params: {params().postId} - - - ) - } - - const postRoute = createRoute({ - getParentRoute: () => postsRoute, - path: '$postId', - component: PostComponent, - }) - - const PostIndexComponent = () => { - return ( - <> -

Post Index

- - ) - } - - const postIndexRoute = createRoute({ - getParentRoute: () => postRoute, - path: '/', - component: PostIndexComponent, - }) - - const DetailsComponent = () => { - return ( - <> -

Details!

- - ) - } - - const detailsRoute = createRoute({ - getParentRoute: () => postRoute, - path: 'details', - component: DetailsComponent, - }) - - const router = createRouter({ - routeTree: rootRoute.addChildren([ - indexRoute, - layoutRoute.addChildren([ - postsRoute.addChildren([ - postRoute.addChildren([postIndexRoute, detailsRoute]), - ]), - ]), - ]), - }) - - render(() => ) - - const postsButton = await screen.findByTestId('index-to-first-post-btn') - - fireEvent.click(postsButton) - - expect(await screen.findByTestId('details-heading')).toBeInTheDocument() - - expect(window.location.pathname).toEqual('/posts/id1/details') - - const homeButton = await screen.findByTestId('btn-to-home') - - fireEvent.click(homeButton) - - expect(await screen.findByTestId('index-heading')).toBeInTheDocument() - expect(window.location.pathname).toEqual('/') - } - - test('Route', () => runTest('Route')) - test('RouteApi', () => runTest('RouteApi')) - }) }) - -describe.each([{ basepath: '' }, { basepath: '/basepath' }])( - 'relative useNavigate with %s', - ({ basepath }) => { - const setupRouter = () => { - const rootRoute = createRootRoute() - const indexRoute = createRoute({ - getParentRoute: () => rootRoute, - path: '/', - component: () => { - return

Index Route

- }, - }) - const aRoute = createRoute({ - getParentRoute: () => rootRoute, - path: 'a', - component: () => { - return ( - <> -

A Route

- - - ) - }, - }) - - const bRoute = createRoute({ - getParentRoute: () => aRoute, - path: 'b', - component: function BRoute() { - const navigate = useNavigate() - return ( - <> -

B Route

- - - ) - }, - }) - - const paramRoute = createRoute({ - getParentRoute: () => rootRoute, - path: 'param/$param', - component: function ParamRoute() { - const navigate = useNavigate() - return ( - <> -

Param Route

- - - - - ) - }, - }) - - const paramARoute = createRoute({ - getParentRoute: () => paramRoute, - path: 'a', - component: function ParamARoute() { - const navigate = useNavigate() - return ( - <> -

Param A Route

- - - - ) - }, - }) - - const paramBRoute = createRoute({ - getParentRoute: () => paramARoute, - path: 'b', - component: function ParamBRoute() { - const navigate = useNavigate() - return ( - <> -

Param B Route

- - - - - ) - }, - }) - - return createRouter({ - routeTree: rootRoute.addChildren([ - indexRoute, - aRoute.addChildren([bRoute]), - paramRoute.addChildren([paramARoute, paramBRoute]), - ]), - - basepath: basepath === '' ? undefined : basepath, - }) - } - - test('should navigate to the parent route', async () => { - const router = setupRouter() - - // Navigate to /a/b - window.history.replaceState(null, 'root', `${basepath}/a/b`) - - render(() => ) - - // Inspect the link to go up a parent - const parentLink = await screen.findByText('Link to Parent') - - // Click the link and ensure the new location - fireEvent.click(parentLink) - await waitFor(() => - expect(window.location.pathname).toBe(`${basepath}/a`), - ) - }) - - test('should navigate to the parent route and keep params', async () => { - const router = setupRouter() - - // Navigate to /param/oldParamValue/a/b - window.history.replaceState(null, 'root', `${basepath}/param/foo/a/b`) - - render(() => ) - - // Inspect the link to go up a parent and keep the params - const parentLink = await screen.findByText('Link to Parent') - - // Click the link and ensure the new location - fireEvent.click(parentLink) - await waitFor(() => - expect(window.location.pathname).toBe(`${basepath}/param/foo/a`), - ) - }) - - test('should navigate to the parent route and change params', async () => { - const router = setupRouter() - // Navigate to /param/oldParamValue/a/b - - window.history.replaceState(null, 'root', `${basepath}/param/foo/a/b`) - render(() => ) - - // Inspect the link to go up a parent and keep the params - const parentLink = await screen.findByText( - 'Link to Parent with param:bar', - ) - - // Click the link and ensure the new location - fireEvent.click(parentLink) - await waitFor(() => - expect(window.location.pathname).toBe(`${basepath}/param/bar/a`), - ) - }) - - test('should navigate to a relative link based on render location with basepath', async () => { - const router = setupRouter() - - window.history.replaceState(null, 'root', `${basepath}/param/foo/a/b`) - - render(() => ) - - // Inspect the relative link to ./a - const relativeLink = await screen.findByText('Link to ./a') - - // Click the link and ensure the new location - fireEvent.click(relativeLink) - await waitFor(() => - expect(window.location.pathname).toBe(`${basepath}/param/foo/a`), - ) - }) - - test('should navigate to a parent link based on render location', async () => { - const router = setupRouter() - - window.history.replaceState(null, 'root', `${basepath}/param/foo/a/b`) - - render(() => ) - - // Inspect the relative link to ./a - const relativeLink = await screen.findByText( - 'Link to .. from /param/foo/a', - ) - - // Click the link and ensure the new location - fireEvent.click(relativeLink) - await waitFor(() => - expect(window.location.pathname).toBe(`${basepath}/param/foo`), - ) - }) - - test('should navigate to same route with different params', async () => { - const router = setupRouter() - - window.history.replaceState(null, 'root', `${basepath}/param/foo/a/b`) - - render(() => ) - - const parentLink = await screen.findByText('Link to . with param:bar') - - fireEvent.click(parentLink) - await waitFor( - () => - expect(window.location.pathname).toBe(`${basepath}/param/bar/a/b`), - {}, - ) - }) - }, -) From de884b8e97acd89aa5980388159c1f922afa6c7f Mon Sep 17 00:00:00 2001 From: Nico Lynzaad Date: Tue, 19 Aug 2025 03:34:31 +0200 Subject: [PATCH 17/25] consolidate from logic --- packages/react-router/src/link.tsx | 33 +++++----------- .../react-router/src/useActiveLocation.ts | 33 ++++++++++++++++ packages/react-router/src/useNavigate.tsx | 22 ++--------- packages/solid-router/src/link.tsx | 26 ++----------- .../solid-router/src/useActiveLocation.ts | 39 +++++++++++++++++++ packages/solid-router/src/useNavigate.tsx | 19 ++------- 6 files changed, 93 insertions(+), 79 deletions(-) create mode 100644 packages/react-router/src/useActiveLocation.ts create mode 100644 packages/solid-router/src/useActiveLocation.ts diff --git a/packages/react-router/src/link.tsx b/packages/react-router/src/link.tsx index c867370290f..348d1f8fc64 100644 --- a/packages/react-router/src/link.tsx +++ b/packages/react-router/src/link.tsx @@ -4,16 +4,18 @@ import { deepEqual, exactPathTest, functionalUpdate, - last, preloadWarning, removeTrailingSlash, } from '@tanstack/router-core' +import { useActiveLocation } from './useActiveLocation' import { useRouterState } from './useRouterState' import { useRouter } from './useRouter' -import { useForwardedRef, useIntersectionObserver } from './utils' +import { + useForwardedRef, + useIntersectionObserver, +} from './utils' -import { useMatch } from './useMatch' import type { AnyRouter, Constrain, @@ -100,37 +102,20 @@ export function useLinkProps< structuralSharing: true as any, }) - const matchIndex = useMatch({ - strict: false, - select: (match) => match.index, - }) + const {getFromPath} = useActiveLocation() - // Track the active location to ensure recomputation on path changes - const activeLocation = useRouterState({ - select: (s) => s.location, - structuralSharing: true as any, - }) + const from = getFromPath(options.from) const _options = React.useMemo( () => { - const currentRouteMatches = router.matchRoutes(activeLocation, { - _buildLocation: false, - }) - - const from = - options.from ?? - last(currentRouteMatches)?.fullPath ?? - router.state.matches[matchIndex]!.fullPath - - return { ...options, from } + return { ...options, from } }, // eslint-disable-next-line react-hooks/exhaustive-deps [ router, currentSearch, - activeLocation, + from, options._fromLocation, - options.from, options.hash, options.to, options.search, diff --git a/packages/react-router/src/useActiveLocation.ts b/packages/react-router/src/useActiveLocation.ts new file mode 100644 index 00000000000..4939475be0b --- /dev/null +++ b/packages/react-router/src/useActiveLocation.ts @@ -0,0 +1,33 @@ +import { last } from '@tanstack/router-core' +import { useRouter } from './useRouter' +import { useMatch } from './useMatch' +import { useRouterState } from './useRouterState' +import type { AnyRouteMatch } from '@tanstack/router-core' + +export type UseLocationResult = {activeLocationMatch: AnyRouteMatch | undefined, getFromPath: (from?: string) => string} + +export const useActiveLocation = (): UseLocationResult => { + const router = useRouter() + + const currentRouteMatch = useMatch({ + strict: false, + select: (match) => match, + }) + + const activeLocation = useRouterState({ + select: (s) => s.location, + structuralSharing: true as any, + }) + + const activeLocationMatches = router.matchRoutes(activeLocation, { + _buildLocation: false, + }) + + const activeLocationMatch = last(activeLocationMatches) + + const getFromPath = (from?: string) => { + return from ?? activeLocationMatch?.fullPath ?? currentRouteMatch.fullPath + } + + return { activeLocationMatch, getFromPath } +} diff --git a/packages/react-router/src/useNavigate.tsx b/packages/react-router/src/useNavigate.tsx index 83b5f63298c..821a2d30056 100644 --- a/packages/react-router/src/useNavigate.tsx +++ b/packages/react-router/src/useNavigate.tsx @@ -1,7 +1,6 @@ import * as React from 'react' -import { last } from '@tanstack/router-core' import { useRouter } from './useRouter' -import { useMatch } from './useMatch' +import { useActiveLocation } from './useActiveLocation' import type { AnyRouter, FromPathOption, @@ -18,24 +17,11 @@ export function useNavigate< }): UseNavigateResult { const router = useRouter() - // Just get the index of the current match to avoid rerenders - // as much as possible - const matchIndex = useMatch({ - strict: false, - select: (match) => match.index, - }) + const { getFromPath, activeLocationMatch } = useActiveLocation() return React.useCallback( (options: NavigateOptions) => { - const currentRouteMatches = router.matchRoutes(router.latestLocation, { - _buildLocation: false, - }) - - const from = - options.from ?? - _defaultOpts?.from ?? - last(currentRouteMatches)?.fullPath ?? - router.state.matches[matchIndex]!.fullPath + const from = getFromPath(options.from ?? _defaultOpts?.from) return router.navigate({ ...options, @@ -43,7 +29,7 @@ export function useNavigate< }) }, // eslint-disable-next-line react-hooks/exhaustive-deps - [_defaultOpts?.from, router.navigate, router.latestLocation, matchIndex], + [_defaultOpts?.from, router, activeLocationMatch], ) as UseNavigateResult } diff --git a/packages/solid-router/src/link.tsx b/packages/solid-router/src/link.tsx index 397abdd693d..d6a096364c5 100644 --- a/packages/solid-router/src/link.tsx +++ b/packages/solid-router/src/link.tsx @@ -6,17 +6,16 @@ import { deepEqual, exactPathTest, functionalUpdate, - last, preloadWarning, removeTrailingSlash, } from '@tanstack/router-core' import { Dynamic } from 'solid-js/web' +import { useActiveLocation } from './useActiveLocation' import { useRouterState } from './useRouterState' import { useRouter } from './useRouter' import { useIntersectionObserver } from './utils' -import { useMatch } from './useMatch' import type { AnyRouter, Constrain, @@ -141,24 +140,8 @@ export function useLinkProps< // select: (match) => options.from ?? match.fullPath, // }) - const matchIndex = useMatch({ - strict: false, - select: (match) => match.index, - }) - - const activeLocation = useRouterState({ select: (s) => s.location }) - - const from = Solid.createMemo(() => { - const currentRouteMatches = router.matchRoutes(activeLocation(), { - _buildLocation: false, - }) - - return ( - options.from ?? - last(currentRouteMatches)?.fullPath ?? - router.state.matches[matchIndex()]!.fullPath - ) - }) + const { getFromPath } = useActiveLocation() + const from = getFromPath(options.from); const _options = () => { return { @@ -169,8 +152,7 @@ export function useLinkProps< const next = Solid.createMemo(() => { currentSearch() - // Access activeLocation to make this memo re-run on route changes - activeLocation() + from() return router.buildLocation(_options() as any) }) diff --git a/packages/solid-router/src/useActiveLocation.ts b/packages/solid-router/src/useActiveLocation.ts new file mode 100644 index 00000000000..92b17acaf5b --- /dev/null +++ b/packages/solid-router/src/useActiveLocation.ts @@ -0,0 +1,39 @@ +import { last } from '@tanstack/router-core' +import { createMemo } from 'solid-js' +import { useRouterState } from './useRouterState' +import { useMatch } from './useMatch' +import { useRouter } from './useRouter' +import type {Accessor} from 'solid-js'; +import type { AnyRouteMatch} from '@tanstack/router-core' + +export type UseActiveLocationResult = { activeLocationMatch: Accessor, getFromPath: (from?: string) => Accessor } + +export function useActiveLocation(): UseActiveLocationResult { + const router = useRouter() + + const currentRouteMatch = useMatch({ + strict: false, + select: (match) => match, + }) + + const activeLocation = useRouterState({ + select: (s) => s.location + }) + + const activeLocationMatch = createMemo(() => { + const activeLocationMatches = router.matchRoutes(activeLocation(), { + _buildLocation: false, + }) + + return last(activeLocationMatches) + }) + + + + + const getFromPath = (from?: string) => { + return createMemo(() => from ?? activeLocationMatch()?.fullPath ?? currentRouteMatch().fullPath) + } + + return { activeLocationMatch, getFromPath } +} diff --git a/packages/solid-router/src/useNavigate.tsx b/packages/solid-router/src/useNavigate.tsx index a0b99751cb7..a467890686f 100644 --- a/packages/solid-router/src/useNavigate.tsx +++ b/packages/solid-router/src/useNavigate.tsx @@ -1,7 +1,6 @@ import * as Solid from 'solid-js' -import { last } from '@tanstack/router-core' import { useRouter } from './useRouter' -import { useMatch } from './useMatch' +import { useActiveLocation } from './useActiveLocation' import type { AnyRouter, FromPathOption, @@ -18,23 +17,13 @@ export function useNavigate< }): UseNavigateResult { const router = useRouter() - const matchIndex = useMatch({ - strict: false, - select: (match) => match.index, - }) - + const {getFromPath} = useActiveLocation() return ((options: NavigateOptions) => { - const currentRouteMatches = router.matchRoutes(router.latestLocation, { - _buildLocation: false, - }) + const from = getFromPath(options.from ?? _defaultOpts?.from) return router.navigate({ ...options, - from: - options.from ?? - _defaultOpts?.from ?? - last(currentRouteMatches)?.fullPath ?? - router.state.matches[matchIndex()]!.fullPath, + from: from() }) }) as UseNavigateResult } From 8feb3c0f34c1b1bda8a46a9f32201a490ead8c60 Mon Sep 17 00:00:00 2001 From: Nico Lynzaad Date: Tue, 19 Aug 2025 05:29:00 +0200 Subject: [PATCH 18/25] revert change to SolidJS useNavigate --- .../solid-router/src/useActiveLocation.ts | 3 --- packages/solid-router/src/useNavigate.tsx | 19 +++++++++++++++---- 2 files changed, 15 insertions(+), 7 deletions(-) diff --git a/packages/solid-router/src/useActiveLocation.ts b/packages/solid-router/src/useActiveLocation.ts index 92b17acaf5b..90ccf13fe23 100644 --- a/packages/solid-router/src/useActiveLocation.ts +++ b/packages/solid-router/src/useActiveLocation.ts @@ -28,9 +28,6 @@ export function useActiveLocation(): UseActiveLocationResult { return last(activeLocationMatches) }) - - - const getFromPath = (from?: string) => { return createMemo(() => from ?? activeLocationMatch()?.fullPath ?? currentRouteMatch().fullPath) } diff --git a/packages/solid-router/src/useNavigate.tsx b/packages/solid-router/src/useNavigate.tsx index a467890686f..a0b99751cb7 100644 --- a/packages/solid-router/src/useNavigate.tsx +++ b/packages/solid-router/src/useNavigate.tsx @@ -1,6 +1,7 @@ import * as Solid from 'solid-js' +import { last } from '@tanstack/router-core' import { useRouter } from './useRouter' -import { useActiveLocation } from './useActiveLocation' +import { useMatch } from './useMatch' import type { AnyRouter, FromPathOption, @@ -17,13 +18,23 @@ export function useNavigate< }): UseNavigateResult { const router = useRouter() - const {getFromPath} = useActiveLocation() + const matchIndex = useMatch({ + strict: false, + select: (match) => match.index, + }) + return ((options: NavigateOptions) => { - const from = getFromPath(options.from ?? _defaultOpts?.from) + const currentRouteMatches = router.matchRoutes(router.latestLocation, { + _buildLocation: false, + }) return router.navigate({ ...options, - from: from() + from: + options.from ?? + _defaultOpts?.from ?? + last(currentRouteMatches)?.fullPath ?? + router.state.matches[matchIndex()]!.fullPath, }) }) as UseNavigateResult } From 452f87672964134de467ff243ef37069da3a1965 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Tue, 19 Aug 2025 03:32:37 +0000 Subject: [PATCH 19/25] ci: apply automated fixes --- packages/react-router/src/link.tsx | 9 +++---- .../react-router/src/useActiveLocation.ts | 5 +++- packages/react-router/src/useNavigate.tsx | 2 +- packages/react-router/tests/link.test.tsx | 6 +---- .../react-router/tests/useNavigate.test.tsx | 27 +++++-------------- packages/solid-router/src/link.tsx | 2 +- .../solid-router/src/useActiveLocation.ts | 18 ++++++++----- .../solid-router/tests/useNavigate.test.tsx | 4 +-- 8 files changed, 30 insertions(+), 43 deletions(-) diff --git a/packages/react-router/src/link.tsx b/packages/react-router/src/link.tsx index 348d1f8fc64..433fda334f9 100644 --- a/packages/react-router/src/link.tsx +++ b/packages/react-router/src/link.tsx @@ -11,10 +11,7 @@ import { useActiveLocation } from './useActiveLocation' import { useRouterState } from './useRouterState' import { useRouter } from './useRouter' -import { - useForwardedRef, - useIntersectionObserver, -} from './utils' +import { useForwardedRef, useIntersectionObserver } from './utils' import type { AnyRouter, @@ -102,13 +99,13 @@ export function useLinkProps< structuralSharing: true as any, }) - const {getFromPath} = useActiveLocation() + const { getFromPath } = useActiveLocation() const from = getFromPath(options.from) const _options = React.useMemo( () => { - return { ...options, from } + return { ...options, from } }, // eslint-disable-next-line react-hooks/exhaustive-deps [ diff --git a/packages/react-router/src/useActiveLocation.ts b/packages/react-router/src/useActiveLocation.ts index 4939475be0b..95b14fd7e74 100644 --- a/packages/react-router/src/useActiveLocation.ts +++ b/packages/react-router/src/useActiveLocation.ts @@ -4,7 +4,10 @@ import { useMatch } from './useMatch' import { useRouterState } from './useRouterState' import type { AnyRouteMatch } from '@tanstack/router-core' -export type UseLocationResult = {activeLocationMatch: AnyRouteMatch | undefined, getFromPath: (from?: string) => string} +export type UseLocationResult = { + activeLocationMatch: AnyRouteMatch | undefined + getFromPath: (from?: string) => string +} export const useActiveLocation = (): UseLocationResult => { const router = useRouter() diff --git a/packages/react-router/src/useNavigate.tsx b/packages/react-router/src/useNavigate.tsx index 821a2d30056..d043ee102aa 100644 --- a/packages/react-router/src/useNavigate.tsx +++ b/packages/react-router/src/useNavigate.tsx @@ -21,7 +21,7 @@ export function useNavigate< return React.useCallback( (options: NavigateOptions) => { - const from = getFromPath(options.from ?? _defaultOpts?.from) + const from = getFromPath(options.from ?? _defaultOpts?.from) return router.navigate({ ...options, diff --git a/packages/react-router/tests/link.test.tsx b/packages/react-router/tests/link.test.tsx index 7a8971b6a30..cb6124db79e 100644 --- a/packages/react-router/tests/link.test.tsx +++ b/packages/react-router/tests/link.test.tsx @@ -6057,11 +6057,7 @@ describe('when on /posts/$postId and navigating to ../ with default `from` /post return ( <>

Posts

- { - navigateVia === 'Route' - ? - : - } + {navigateVia === 'Route' ? : } ) diff --git a/packages/react-router/tests/useNavigate.test.tsx b/packages/react-router/tests/useNavigate.test.tsx index ebd2f0a21df..fa731c0b16b 100644 --- a/packages/react-router/tests/useNavigate.test.tsx +++ b/packages/react-router/tests/useNavigate.test.tsx @@ -1573,9 +1573,7 @@ test.each([true, false])( fireEvent.click(postsButton) - expect( - await screen.findByTestId('posts-index-heading'), - ).toBeInTheDocument() + expect(await screen.findByTestId('posts-index-heading')).toBeInTheDocument() expect(window.location.pathname).toEqual(`/posts${tail}`) const firstPostButton = await screen.findByTestId('first-post-btn') @@ -1741,9 +1739,7 @@ test.each([true, false])( fireEvent.click(postsButton) - expect( - await screen.findByTestId('posts-index-heading'), - ).toBeInTheDocument() + expect(await screen.findByTestId('posts-index-heading')).toBeInTheDocument() const post1Button = await screen.findByTestId('first-post-btn') @@ -1873,7 +1869,7 @@ test.each([true, false])( expect(router.state.location.pathname).toBe(`/`) expect(router.state.location.search).toEqual({}) - } + }, ) test.each([true, false])( @@ -2058,9 +2054,7 @@ test.each([true, false])( fireEvent.click(postsButton) - expect( - await screen.findByTestId('posts-index-heading'), - ).toBeInTheDocument() + expect(await screen.findByTestId('posts-index-heading')).toBeInTheDocument() expect(window.location.pathname).toEqual(`/posts${tail}`) const firstPostButton = await screen.findByTestId('first-post-btn') @@ -2095,18 +2089,14 @@ test.each([true, false])( fireEvent.click(postNotesButton) - expect( - await screen.findByTestId('post-notes-heading'), - ).toBeInTheDocument() + 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(await screen.findByTestId('posts-index-heading')).toBeInTheDocument() expect(screen.queryByTestId('post-notes-heading')).not.toBeInTheDocument() expect( screen.queryByTestId('post-detail-index-heading'), @@ -2357,9 +2347,7 @@ describe.each([{ basepath: '' }, { basepath: '/basepath' }])( Link to .. from /param/foo/a