diff --git a/docs/router/framework/react/guide/navigation.md b/docs/router/framework/react/guide/navigation.md index 26b8a02ce3f..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. @@ -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 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({ @@ -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 diff --git a/packages/react-router/src/link.tsx b/packages/react-router/src/link.tsx index c72d0e85678..6fea974ffeb 100644 --- a/packages/react-router/src/link.tsx +++ b/packages/react-router/src/link.tsx @@ -7,12 +7,12 @@ import { preloadWarning, removeTrailingSlash, } from '@tanstack/router-core' +import { useActiveLocation } from './useActiveLocation' import { useRouterState } from './useRouterState' import { useRouter } from './useRouter' import { useForwardedRef, useIntersectionObserver } from './utils' -import { useMatch } from './useMatch' import type { AnyRouter, Constrain, @@ -99,19 +99,27 @@ export function useLinkProps< structuralSharing: true as any, }) - const from = useMatch({ - strict: false, - select: (match) => options.from ?? match.fullPath, + // subscribe to location here to re-build fromPath if it changes + const routerLocation = useRouterState({ + select: (s) => s.location, + structuralSharing: true as any, }) - const next = React.useMemo( - () => router.buildLocation({ ...options, from } as any), + const { getFromPath } = useActiveLocation() + + const from = getFromPath(options.from) + + const _options = React.useMemo( + () => { + return { ...options, from } + }, // eslint-disable-next-line react-hooks/exhaustive-deps [ router, + routerLocation, currentSearch, - options._fromLocation, from, + options._fromLocation, options.hash, options.to, options.search, @@ -122,6 +130,11 @@ export function useLinkProps< ], ) + const next = React.useMemo( + () => router.buildLocation({ ..._options } as any), + [router, _options], + ) + const isExternal = type === 'external' const preload = @@ -180,34 +193,12 @@ export function useLinkProps< }, }) - const doPreload = React.useCallback( - () => { - router.preloadRoute({ ...options, from } as any).catch((err) => { - console.warn(err) - console.warn(preloadWarning) - }) - }, - // eslint-disable-next-line react-hooks/exhaustive-deps - [ - router, - options.to, - options._fromLocation, - 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, - ], - ) + 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) => { @@ -235,25 +226,6 @@ 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 }), - } - } - // The click handler const handleClick = (e: React.MouseEvent) => { if ( @@ -277,8 +249,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, + ..._options, replace, resetScroll, hashScrollIntoView, @@ -289,6 +260,25 @@ export function useLinkProps< } } + 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 }), + } + } + // The click handler const handleFocus = (_: React.MouseEvent) => { if (disabled) return diff --git a/packages/react-router/src/useActiveLocation.ts b/packages/react-router/src/useActiveLocation.ts new file mode 100644 index 00000000000..c7220fdaca0 --- /dev/null +++ b/packages/react-router/src/useActiveLocation.ts @@ -0,0 +1,57 @@ +import { last } from '@tanstack/router-core' +import { useCallback, useEffect, useState } from 'react' +import { useRouter } from './useRouter' +import { useMatch } from './useMatch' +import { useRouterState } from './useRouterState' +import type { ParsedLocation } from '@tanstack/router-core' + +export type UseActiveLocationResult = { + activeLocation: ParsedLocation + getFromPath: (from?: string) => string + setActiveLocation: (location?: ParsedLocation) => void +} + +export const useActiveLocation = ( + location?: ParsedLocation, +): UseActiveLocationResult => { + const router = useRouter() + const routerLocation = useRouterState({ select: (state) => state.location }) + const [activeLocation, setActiveLocation] = useState( + location ?? routerLocation, + ) + const [customActiveLocation, setCustomActiveLocation] = useState< + ParsedLocation | undefined + >(location) + + useEffect(() => { + setActiveLocation(customActiveLocation ?? routerLocation) + }, [routerLocation, customActiveLocation]) + + const matchIndex = useMatch({ + strict: false, + select: (match) => match.index, + }) + + const getFromPath = useCallback( + (from?: string) => { + const activeLocationMatches = router.matchRoutes(activeLocation, { + _buildLocation: false, + }) + + const activeLocationMatch = last(activeLocationMatches) + + return ( + from ?? + activeLocationMatch?.fullPath ?? + router.state.matches[matchIndex]!.fullPath + ) + }, + [activeLocation, matchIndex, router], + ) + + return { + activeLocation, + getFromPath, + setActiveLocation: setCustomActiveLocation, + } +} diff --git a/packages/react-router/src/useNavigate.tsx b/packages/react-router/src/useNavigate.tsx index 1fcef979673..c12cffb9c93 100644 --- a/packages/react-router/src/useNavigate.tsx +++ b/packages/react-router/src/useNavigate.tsx @@ -1,6 +1,6 @@ import * as React from 'react' import { useRouter } from './useRouter' -import { useMatch } from './useMatch' +import { useActiveLocation } from './useActiveLocation' import type { AnyRouter, FromPathOption, @@ -15,29 +15,21 @@ 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 - const matchIndex = useMatch({ - strict: false, - select: (match) => match.index, - }) + const { getFromPath, activeLocation } = useActiveLocation() return React.useCallback( (options: NavigateOptions) => { - const from = - options.from ?? - _defaultOpts?.from ?? - state.matches[matchIndex]!.fullPath + const from = getFromPath(options.from ?? _defaultOpts?.from) - return navigate({ + return router.navigate({ ...options, from, }) }, // eslint-disable-next-line react-hooks/exhaustive-deps - [_defaultOpts?.from, navigate], + [_defaultOpts?.from, router, getFromPath, activeLocation], ) as UseNavigateResult } diff --git a/packages/react-router/tests/link.test.tsx b/packages/react-router/tests/link.test.tsx index 80578b6f4a3..cb6124db79e 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, @@ -5073,7 +5074,9 @@ describe.each([{ basepath: '' }, { basepath: '/basepath' }])( return ( <>

Param Route

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

Param A Route

- Link to .. from /param/foo/a + + Link to .. from /param/foo/a + + + Link to .. from current active route + ) @@ -5280,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() @@ -5407,3 +5436,712 @@ 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 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') + + 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 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') + + 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 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') + + 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}`) + }, + ) +}) + +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') + + await act(() => fireEvent.click(postButton)) + + expect(router.state.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 homeBtn = await screen.findByTestId('home-link') + + await act(() => 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 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) + + 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 LinkViaRoute = () => ( + + To Home + + ) + + const LinkViaRouteApi = () => { + const RouteApiLink = getRouteApi('/_layout/posts').Link + return ( + + To Home + + ) + } + + return ( + <> +

Posts

+ {navigateVia === 'Route' ? : } + + + ) + } + + 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..fa731c0b16b 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

@@ -1619,7 +1620,7 @@ test.each([true, false])( }) const PostsComponent = () => { - const navigate = postsRoute.useNavigate() + const navigate = useNavigate() return ( <>

Posts

@@ -1776,6 +1777,343 @@ test.each([true, false])( }, ) +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') + + await act(() => fireEvent.click(postButton)) + + expect(router.state.location.pathname).toBe(`/post${tail}`) + + const searchButton = await screen.findByTestId('search-btn') + + 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') + + await act(() => 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 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) + + 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 +2313,11 @@ describe.each([{ basepath: '' }, { basepath: '/basepath' }])( return ( <>

Param Route

- + ) @@ -2150,6 +2502,25 @@ 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/router-core/src/router.ts b/packages/router-core/src/router.ts index 6a132b57831..807c296abf9 100644 --- a/packages/router-core/src/router.ts +++ b/packages/router-core/src/router.ts @@ -1414,50 +1414,44 @@ 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)! - // 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 routeIsChanging = - !!dest.to && - !comparePaths(dest.to.toString(), fromPath) && - !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 + // 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 = findLast(allCurrentLocationMatches, (d) => { - return comparePaths(d.fullPath, fromPath) - }) + const matchedFrom = findLast(allCurrentLocationMatches, (d) => { + return comparePaths(d.fullPath, dest.from!) + }) - const matchedCurrent = findLast(allFromMatches, (d) => { - return comparePaths(d.fullPath, currentLocation.pathname) - }) + 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 - if (!matchedFrom && !matchedCurrent) { - console.warn(`Could not find match for from: ${fromPath}`) - } + // 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, '.') + 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 @@ -1465,6 +1459,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, '.') diff --git a/packages/solid-router/src/link.tsx b/packages/solid-router/src/link.tsx index 008483ac0d8..00c0c109972 100644 --- a/packages/solid-router/src/link.tsx +++ b/packages/solid-router/src/link.tsx @@ -15,7 +15,7 @@ import { useRouter } from './useRouter' import { useIntersectionObserver } from './utils' -import { useMatch } from './useMatch' +import { useActiveLocation } from './useActiveLocation' import type { AnyRouter, Constrain, @@ -133,20 +133,20 @@ export function useLinkProps< select: (s) => s.location.searchStr, }) - // 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({ - strict: false, - select: (match) => options.from ?? match.fullPath, - }) + const { getFromPath, activeLocation } = useActiveLocation() - const _options = () => ({ - ...options, - from: from(), - }) + const from = getFromPath(options.from) + + const _options = () => { + return { + ...options, + from: from(), + } + } const next = Solid.createMemo(() => { currentSearch() + activeLocation() 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..268af7296dd --- /dev/null +++ b/packages/solid-router/src/useActiveLocation.ts @@ -0,0 +1,61 @@ +import { last } from '@tanstack/router-core' +import { createEffect, createMemo, createSignal } from 'solid-js' +import { useMatch } from './useMatch' +import { useRouter } from './useRouter' +import { useRouterState } from './useRouterState' +import type { Accessor } from 'solid-js' +import type { ParsedLocation } from '@tanstack/router-core' + +export type UseLocationResult = { + activeLocation: Accessor + getFromPath: (from?: string) => Accessor + setActiveLocation: (location?: ParsedLocation) => void +} + +export function useActiveLocation( + location?: ParsedLocation, +): UseLocationResult { + const router = useRouter() + // we are not using a variable here for router state location since we need to only calculate that if the location is not passed in. It can result in unnecessary history actions if we do that. + const [activeLocation, setActiveLocation] = createSignal( + location ?? useRouterState({ select: (s) => s.location })(), + ) + const [customActiveLocation, setCustomActiveLocation] = createSignal< + ParsedLocation | undefined + >(location) + + createEffect(() => { + setActiveLocation( + customActiveLocation() ?? useRouterState({ select: (s) => s.location })(), + ) + }) + + const matchIndex = useMatch({ + strict: false, + select: (match) => match.index, + }) + + const getFromPath = (from?: string) => + createMemo(() => { + const activeLocationMatches = router.matchRoutes( + customActiveLocation() ?? activeLocation(), + { + _buildLocation: false, + }, + ) + + const activeLocationMatch = last(activeLocationMatches) + + return ( + from ?? + activeLocationMatch?.fullPath ?? + router.state.matches[matchIndex()]!.fullPath + ) + }) + + return { + activeLocation, + getFromPath, + setActiveLocation: setCustomActiveLocation, + } +} diff --git a/packages/solid-router/src/useNavigate.tsx b/packages/solid-router/src/useNavigate.tsx index 96d1207533b..0a7f7437531 100644 --- a/packages/solid-router/src/useNavigate.tsx +++ b/packages/solid-router/src/useNavigate.tsx @@ -1,6 +1,6 @@ import * as Solid from 'solid-js' import { useRouter } from './useRouter' -import { useMatch } from './useMatch' +import { useActiveLocation } from './useActiveLocation' import type { AnyRouter, FromPathOption, @@ -15,20 +15,19 @@ export function useNavigate< >(_defaultOpts?: { from?: FromPathOption }): UseNavigateResult { - const { navigate, state } = useRouter() + const router = useRouter() - const matchIndex = useMatch({ - strict: false, - select: (match) => match.index, - }) + const { getFromPath, setActiveLocation } = useActiveLocation( + router.latestLocation, + ) return ((options: NavigateOptions) => { - return navigate({ + setActiveLocation(router.latestLocation) + const from = getFromPath(options.from ?? _defaultOpts?.from) + + return router.navigate({ ...options, - from: - options.from ?? - _defaultOpts?.from ?? - state.matches[matchIndex()]!.fullPath, + from: from(), }) }) as UseNavigateResult } diff --git a/packages/solid-router/tests/link.test.tsx b/packages/solid-router/tests/link.test.tsx index 9eba08fd094..ae751d4386f 100644 --- a/packages/solid-router/tests/link.test.tsx +++ b/packages/solid-router/tests/link.test.tsx @@ -4636,7 +4636,9 @@ describe.each([{ basepath: '' }, { basepath: '/basepath' }])( return ( <>

Param Route

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

Param A Route

- Link to .. from /param/foo/a + + Link to .. from /param/foo/a + + + Link to .. from current active route + ) @@ -4833,6 +4840,25 @@ describe.each([{ basepath: '' }, { basepath: '/basepath' }])( ) }) + 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') + + 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() @@ -4937,9 +4963,600 @@ describe.each([{ basepath: '' }, { basepath: '/basepath' }])( fireEvent.click(parentLink) - waitFor(() => + 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 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}`) + }) + }, + ) +}) diff --git a/packages/solid-router/tests/useNavigate.test.tsx b/packages/solid-router/tests/useNavigate.test.tsx index cf9ef4831db..d3eb186cb21 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,8 +22,17 @@ import { useNavigate, useParams, } from '../src' +import type { RouterHistory } from '../src' + +let history: RouterHistory + +beforeEach(() => { + history = createBrowserHistory() + expect(window.location.pathname).toBe('/') +}) afterEach(() => { + history.destroy() window.history.replaceState(null, 'root', '/') cleanup() }) @@ -1509,7 +1519,11 @@ describe.each([{ basepath: '' }, { basepath: '/basepath' }])( return ( <>

Param Route

- + ) @@ -1668,6 +1692,23 @@ describe.each([{ basepath: '' }, { basepath: '/basepath' }])( ) }) + 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() @@ -1686,3 +1727,799 @@ describe.each([{ basepath: '' }, { basepath: '/basepath' }])( }) }, ) + +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 ( + <> + + + + + + ) + } + + 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 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}`], + }) + + 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 }) + }) + }, + ) +}) + +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}`) + }) + }, + ) +})