From 937d4f12b0503acc06c4820e0a1d336c190cc461 Mon Sep 17 00:00:00 2001 From: Tanner Linsley Date: Wed, 27 Aug 2025 14:58:39 -0600 Subject: [PATCH 01/34] feat: url rewrites --- packages/history/src/index.ts | 23 +++++++--- packages/react-router/src/link.tsx | 4 +- packages/router-core/src/location.ts | 44 ++++++++++++++++++ packages/router-core/src/router.ts | 67 +++++++++++++++++++++++++--- packages/solid-router/src/link.tsx | 4 +- 5 files changed, 125 insertions(+), 17 deletions(-) diff --git a/packages/history/src/index.ts b/packages/history/src/index.ts index 3178cb90bbf..01fa5013113 100644 --- a/packages/history/src/index.ts +++ b/packages/history/src/index.ts @@ -44,6 +44,7 @@ export interface HistoryLocation extends ParsedPath { } export interface ParsedPath { + url: URL href: string pathname: string search: string @@ -295,14 +296,10 @@ export function createBrowserHistory(opts?: { const _setBlockers = (newBlockers: Array) => (blockers = newBlockers) - const createHref = opts?.createHref ?? ((path) => path) + const createHref = opts?.createHref ?? ((href) => href) const parseLocation = opts?.parseLocation ?? - (() => - parseHref( - `${win.location.pathname}${win.location.search}${win.location.hash}`, - win.history.state, - )) + (() => parseHref(win.location.href, win.history.state)) // Ensure there is always a key to start if (!win.history.state?.__TSR_key && !win.history.state?.key) { @@ -561,7 +558,10 @@ export function createHashHistory(opts?: { window?: any }): RouterHistory { return parseHref(hashHref, win.history.state) }, createHref: (href) => - `${win.location.pathname}${win.location.search}#${href}`, + new URL( + `${win.location.pathname}${win.location.search}#${href}`, + win.location.origin, + ).toString(), }) } @@ -617,12 +617,21 @@ export function parseHref( href: string, state: ParsedHistoryState | undefined, ): HistoryLocation { + let url!: URL + + try { + url = new URL(href) + } catch { + url = new URL(href, 'http://localhost') + } + const hashIndex = href.indexOf('#') const searchIndex = href.indexOf('?') const addedKey = createRandomKey() return { + url, href, pathname: href.substring( 0, diff --git a/packages/react-router/src/link.tsx b/packages/react-router/src/link.tsx index c72d0e85678..8955c6be45a 100644 --- a/packages/react-router/src/link.tsx +++ b/packages/react-router/src/link.tsx @@ -361,8 +361,8 @@ export function useLinkProps< href: disabled ? undefined : next.maskedLocation - ? router.history.createHref(next.maskedLocation.href) - : router.history.createHref(next.href), + ? next.maskedLocation.url.href + : next.url.href, ref: innerRef as React.ComponentPropsWithRef<'a'>['ref'], onClick: composeHandlers([onClick, handleClick]), onFocus: composeHandlers([onFocus, handleFocus]), diff --git a/packages/router-core/src/location.ts b/packages/router-core/src/location.ts index 4b09f7b7ff7..a14df74e6d8 100644 --- a/packages/router-core/src/location.ts +++ b/packages/router-core/src/location.ts @@ -2,12 +2,56 @@ import type { ParsedHistoryState } from '@tanstack/history' import type { AnySchema } from './validators' export interface ParsedLocation { + /** + * The URL instance representing the location. In future versions of TanStack Router, + * this will be the preferred way to access basic url information. This will + * not include derived properties like `fullPath`, the `search` object, + * `maskedLocation`, or anything else that can already be found on the `URL` class. + */ + url: URL + /** + * @deprecated In future versions of TanStack Router, `href` will + * represent the full URL, including the origin. As a replacement, + * please upgrade to the new `fullPath` property, which is derived by + * combining `pathname`, `search`, and `hash`. like so: + * `${pathname}${searchStr}${hash}`. If you'r looking for the actual + * `href` of the location, you can use the `location.url.href` property. + */ href: string + /** + * The full path of the location, including pathname, search, and hash. + * Does not include the origin. Is the equivalent of calling + * `url.replace(url.origin, '')` + */ + fullPath: string + /** + * @deprecated In future versions of TanStack Router, this property will be removed. + * Please use the `location.url.pathname` property instead. + * The pathname of the location, including the leading slash. + */ pathname: string + /** + * The parsed search parameters of the location in object form. + */ search: TSearchObj + /** + * The search string of the location, including the leading question mark. + */ searchStr: string + /** + * The in-memory state of the location as it *may* exist in the browser's history. + */ state: ParsedHistoryState + /** + * The hash of the location, including the leading hash character. + */ hash: string + /** + * The masked location of the location. + */ maskedLocation?: ParsedLocation + /** + * Whether to unmask the location on reload. + */ unmaskOnReload?: boolean } diff --git a/packages/router-core/src/router.ts b/packages/router-core/src/router.ts index 223984cf092..afe53973892 100644 --- a/packages/router-core/src/router.ts +++ b/packages/router-core/src/router.ts @@ -431,6 +431,27 @@ export interface RouterOptions< disableGlobalCatchBoundary?: boolean serializationAdapters?: TSerializationAdapters + /** + * Configures how the router will rewrite the location between the actual URL and the internal URL of the router. + * + * @default undefined + */ + rewrite?: { + /** + * A function that will be called to rewrite the URL before it is interpreted by the router from the history instance. + * + * @default undefined + * @returns The rewritten URL or undefined if no rewrite is needed. + */ + fromURL?: ({ url }: { url: URL }) => undefined | URL | string + /** + * A function that will be called to rewrite the URL before it is committed to the actual history instance from the router. + * + * @default undefined + * @returns The rewritten URL or undefined if no rewrite is needed. + */ + toURL?: ({ url }: { url: URL }) => undefined | URL | string + } } export interface RouterState< @@ -1030,20 +1051,37 @@ export class RouterCore< previousLocation, ) => { const parse = ({ - pathname, - search, - hash, + url, state, }: HistoryLocation): ParsedLocation> => { + // Before we do any processing, we need to allow rewrites to modify the URL + if (this.options.rewrite?.toURL) { + url = new URL(this.options.rewrite.toURL({ url }) || url) + } + + // Make sure we derive all the properties we need from the URL object now + // (These used to come from the history location object, but won't in v2) + + const { pathname, search, hash } = url + const parsedSearch = this.options.parseSearch(search) const searchStr = this.options.stringifySearch(parsedSearch) + // Make sure our final url uses the re-stringified pathname, search, and has for consistency + // (We were already doing this, so just keeping it for now) + url.search = searchStr + + const fullPath = url.href.replace(url.origin, '') + return { + url, + fullPath, pathname, searchStr, search: replaceEqualDeep(previousLocation?.search, parsedSearch) as any, hash: hash.split('#').reverse()[0] ?? '', - href: `${pathname}${searchStr}${hash}`, + // TODO: v2 needs to supply the actual href (full URL) instead of the fullPath + href: fullPath, state: replaceEqualDeep(previousLocation?.state, state), } } @@ -1606,14 +1644,31 @@ export class RouterCore< // Replace the equal deep nextState = replaceEqualDeep(currentLocation.state, nextState) + // Create the full path of the location + const fullPath = `${nextPathname}${searchStr}${hashStr}` + + // Create the URL object + let url = new URL(fullPath, currentLocation.url.origin) + + // If a rewrite function is provided, use it to rewrite the URL + if (this.options.rewrite?.toURL) { + url = new URL(this.options.rewrite.toURL({ url }) || url) + } + + // Lastly, allow the history type to modify the URL + url = new URL(this.history.createHref(url.toString())) + // Return the next location return { + url, pathname: nextPathname, search: nextSearch, searchStr, state: nextState as any, hash: hash ?? '', - href: `${nextPathname}${searchStr}${hashStr}`, + fullPath, + // TODO: v2 needs to supply the actual href (full URL) instead of the fullPath + href: fullPath, unmaskOnReload: dest.unmaskOnReload, } } @@ -1816,7 +1871,7 @@ export class RouterCore< if (reloadDocument) { if (!href) { const location = this.buildLocation({ to, ...rest } as any) - href = this.history.createHref(location.href) + href = location.url.href } if (rest.replace) { window.location.replace(href) diff --git a/packages/solid-router/src/link.tsx b/packages/solid-router/src/link.tsx index 008483ac0d8..cd8dbf55428 100644 --- a/packages/solid-router/src/link.tsx +++ b/packages/solid-router/src/link.tsx @@ -391,8 +391,8 @@ export function useLinkProps< return _options().disabled ? undefined : maskedLocation - ? router.history.createHref(maskedLocation.href) - : router.history.createHref(nextLocation?.href) + ? maskedLocation.url.href + : nextLocation?.url.href }) return Solid.mergeProps( From 4d244265f00dcc9398071402cd6ca6f6035675e1 Mon Sep 17 00:00:00 2001 From: Nico Lynzaad Date: Sun, 17 Aug 2025 03:30:24 +0200 Subject: [PATCH 02/34] 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 8955c6be45a..6cff0932e2a 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 afe53973892..23d24377e5e 100644 --- a/packages/router-core/src/router.ts +++ b/packages/router-core/src/router.ts @@ -1493,14 +1493,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 0bf006a74300763d1d2d2c3872216d1100043b12 Mon Sep 17 00:00:00 2001 From: Nico Lynzaad Date: Sun, 17 Aug 2025 03:31:02 +0200 Subject: [PATCH 03/34] 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 80578b6f4a3..8d0701563e1 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 8ad3f0aa85d1c07d6b12f177a41ff370562736fe Mon Sep 17 00:00:00 2001 From: Nico Lynzaad Date: Sun, 17 Aug 2025 14:06:19 +0200 Subject: [PATCH 04/34] 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 8d0701563e1..14a07f3e7af 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 f54d35a80d0b7dbaaf95b4d218712a561242b81e Mon Sep 17 00:00:00 2001 From: Nico Lynzaad Date: Sun, 17 Aug 2025 14:22:02 +0200 Subject: [PATCH 05/34] code cleanup --- packages/react-router/src/link.tsx | 24 ++++----- packages/router-core/src/router.ts | 84 +++++++++++++----------------- 2 files changed, 45 insertions(+), 63 deletions(-) diff --git a/packages/react-router/src/link.tsx b/packages/react-router/src/link.tsx index 6cff0932e2a..0c33cbd46d4 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 23d24377e5e..da5c1c091b6 100644 --- a/packages/router-core/src/router.ts +++ b/packages/router-core/src/router.ts @@ -8,7 +8,6 @@ import invariant from 'tiny-invariant' import { createControlledPromise, deepEqual, - findLast, functionalUpdate, last, replaceEqualDeep, @@ -1488,60 +1487,46 @@ 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 = findLast(allCurrentLocationMatches, (d) => { - return comparePaths(d.fullPath, fromPath) + // 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!) }) - const matchedCurrent = findLast(allFromMatches, (d) => { - return comparePaths(d.fullPath, currentLocation.pathname) - }) + 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: ${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, '.') + // 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 @@ -1549,6 +1534,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 e69b39388d204fa194b2c6ad354eb78767734c1f Mon Sep 17 00:00:00 2001 From: Nico Lynzaad Date: Sun, 17 Aug 2025 14:27:36 +0200 Subject: [PATCH 06/34] 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 14a07f3e7af..90c884cfdf2 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 012c425da2ec1a9dc77e5d359c84bc713827b9a1 Mon Sep 17 00:00:00 2001 From: Nico Lynzaad Date: Sun, 17 Aug 2025 15:23:55 +0200 Subject: [PATCH 07/34] 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 cd8dbf55428..c35c6b31719 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 9eba08fd094..7750b2d810d 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 c681d9d422b3b59b5a284437827c3e0d80ecff54 Mon Sep 17 00:00:00 2001 From: Nico Lynzaad Date: Sun, 17 Aug 2025 15:31:46 +0200 Subject: [PATCH 08/34] 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 c4b851ec3e079818656731dd3a650c2b5bf50a56 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 09/34] 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/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 +++-- 8 files changed, 227 insertions(+), 178 deletions(-) diff --git a/packages/react-router/src/link.tsx b/packages/react-router/src/link.tsx index 0c33cbd46d4..b83e8acc307 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 90c884cfdf2..34461d51b4c 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/solid-router/src/link.tsx b/packages/solid-router/src/link.tsx index c35c6b31719..769ea99c319 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 7750b2d810d..21b045cbf1f 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 0f99450e6b88ad18bba0966dfa474a4c5b150c95 Mon Sep 17 00:00:00 2001 From: Nico Lynzaad Date: Sun, 17 Aug 2025 21:30:20 +0200 Subject: [PATCH 10/34] resolve merge issue --- packages/router-core/src/router.ts | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/packages/router-core/src/router.ts b/packages/router-core/src/router.ts index da5c1c091b6..3021593b867 100644 --- a/packages/router-core/src/router.ts +++ b/packages/router-core/src/router.ts @@ -1499,11 +1499,9 @@ export class RouterCore< undefined, ).matchedRoutes - const matchedFrom = [...allCurrentLocationMatches] - .reverse() - .find((d) => { - return comparePaths(d.fullPath, dest.from!) - }) + const matchedFrom = findLast(allCurrentLocationMatches, (d) => { + return comparePaths(d.fullPath, dest.from!) + }) const matchedCurrent = [...allFromMatches].reverse().find((d) => { return comparePaths(d.fullPath, currentLocation.pathname) From f9e1b8e17c1f252d37a7fd7b7fd33a613f92469a Mon Sep 17 00:00:00 2001 From: Nico Lynzaad Date: Sun, 17 Aug 2025 22:06:44 +0200 Subject: [PATCH 11/34] 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 | 12 ++++++------ 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, 29 insertions(+), 22 deletions(-) diff --git a/packages/react-router/src/link.tsx b/packages/react-router/src/link.tsx index b83e8acc307..a915495d526 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 3021593b867..08d456f1640 100644 --- a/packages/router-core/src/router.ts +++ b/packages/router-core/src/router.ts @@ -1487,6 +1487,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 ( @@ -1503,8 +1507,8 @@ export class RouterCore< return comparePaths(d.fullPath, dest.from!) }) - const matchedCurrent = [...allFromMatches].reverse().find((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 @@ -1514,10 +1518,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 769ea99c319..2d4c799f749 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 7838847f66b2512fc7184ff01ce5ad8b3d7642f3 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 12/34] 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 +--- 3 files changed, 3 insertions(+), 7 deletions(-) diff --git a/packages/react-router/src/link.tsx b/packages/react-router/src/link.tsx index a915495d526..ed79675d8ad 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() From 0e3e0fd7b33939f53b1f7c6aef45e6cbf348f102 Mon Sep 17 00:00:00 2001 From: Nico Lynzaad Date: Sun, 17 Aug 2025 22:15:55 +0200 Subject: [PATCH 13/34] 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 75243b6dee98242bcecfc736a33fa9eba48ca8d7 Mon Sep 17 00:00:00 2001 From: Nico Lynzaad Date: Sun, 17 Aug 2025 22:22:17 +0200 Subject: [PATCH 14/34] 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 7be1f7d391c66309aa36098b086d2e3eca39df94 Mon Sep 17 00:00:00 2001 From: Nico Lynzaad Date: Sun, 17 Aug 2025 23:22:40 +0200 Subject: [PATCH 15/34] 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 ed79675d8ad..4e3939e44f2 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 2d4c799f749..b833bac571e 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 b70d1985ef7409ba6568b229a8070c3618205490 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 16/34] 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 4e3939e44f2..d5fbe2e5a08 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 b833bac571e..79aed4f8f9b 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 fb75b9d81270474370445c77e8c87da7a3e0b745 Mon Sep 17 00:00:00 2001 From: Nico Lynzaad Date: Tue, 19 Aug 2025 00:12:47 +0200 Subject: [PATCH 17/34] 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 2acf67083610faebb171a938f06caa258bde85ed Mon Sep 17 00:00:00 2001 From: Nico Lynzaad Date: Tue, 19 Aug 2025 03:34:31 +0200 Subject: [PATCH 18/34] 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 d5fbe2e5a08..41294f1947d 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 79aed4f8f9b..27533134a28 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 8bd57a284f01ae8ec4e6bf367a862d606c6edcef Mon Sep 17 00:00:00 2001 From: Nico Lynzaad Date: Tue, 19 Aug 2025 05:29:00 +0200 Subject: [PATCH 19/34] 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 9c278a1ada9bdfbaa12748174b89437465daab1e 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 20/34] 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 41294f1947d..944314b7e94 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 + ) + } + + const rootRoute = createRootRoute({ + component: () => , + }) + + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: Navigate, + }) + + const dashboardRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/dashboard', + component: () =>
Dashboard
, + }) + + const routeTree = rootRoute.addChildren([indexRoute, dashboardRoute]) + + const history = createMemoryHistory({ initialEntries: ['/'] }) + const router = createRouter({ + routeTree, + history, + rewrite: { + toURL: ({ url }) => { + // Should rewrite dashboard URLs to admin URLs in the history + if (url.pathname === '/dashboard') { + return '/admin/panel' + } + return undefined + }, + }, + }) + + render() + + const navigateBtn = await screen.findByTestId('navigate-btn') + + await act(() => { + fireEvent.click(navigateBtn) + }) + + await waitFor(() => { + expect(screen.getByTestId('dashboard')).toBeInTheDocument() + }) + + // Router internal state should show the internal path + expect(router.state.location.pathname).toBe('/dashboard') + + // EXPECTED: History should be updated with the rewritten path due to toURL + // ACTUAL: Currently fails - history.location.pathname remains '/dashboard' + expect(history.location.pathname).toBe('/admin/panel') + }) + + it.skip('should handle toURL rewrite with Link navigation (PENDING: toURL not implemented)', async () => { + // This test demonstrates expected toURL behavior for Link-based navigation + const rootRoute = createRootRoute({ + component: () => , + }) + + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: () => ( +
+ + Go to Profile + +
+ ), + }) + + const profileRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/profile', + component: () =>
User Profile
, + }) + + const routeTree = rootRoute.addChildren([indexRoute, profileRoute]) + + const history = createMemoryHistory({ initialEntries: ['/'] }) + const router = createRouter({ + routeTree, + history, + rewrite: { + toURL: ({ url }) => { + // Should rewrite profile URLs to user URLs in history + if (url.pathname === '/profile') { + url.pathname = '/user' + return url + } + return undefined + }, + }, + }) + + render() + + const profileLink = await screen.findByTestId('profile-link') + + await act(() => { + fireEvent.click(profileLink) + }) + + await waitFor(() => { + expect(screen.getByTestId('profile')).toBeInTheDocument() + }) + + expect(router.state.location.pathname).toBe('/profile') + + // EXPECTED: History should show rewritten path + // ACTUAL: Currently fails - history shows original path + expect(history.location.pathname).toBe('/user') + }) + + it.skip('should handle toURL with search params and hash (PENDING: toURL not implemented)', async () => { + // This test would verify toURL rewriting with complex URL components + // Currently skipped as toURL functionality is not working as expected + }) + + it.skip('should handle toURL returning fully formed href string (PENDING: toURL not implemented)', async () => { + // This test would verify toURL returning complete URLs with origins + // Currently skipped as toURL functionality is not working as expected + }) +}) + +describe('rewriteBasepath utility', () => { + // Helper function to create basepath rewrite logic (mimicking the utility) + const createBasepathRewrite = ( + basepath: string, + additionalRewrite?: { + fromURL: (opts: { url: URL }) => URL | undefined + }, + ) => { + const trimmedBasepath = basepath.replace(/^\/+|\/+$/g, '') // trim slashes + return { + fromURL: ({ url }: { url: URL }) => { + if (trimmedBasepath) { + url.pathname = url.pathname.replace( + new RegExp(`^/${trimmedBasepath}`), + '', + ) + } + return additionalRewrite?.fromURL + ? additionalRewrite.fromURL({ url }) + : url + }, + } as const + } + + it('should handle basic basepath rewriting with fromURL', async () => { + const rootRoute = createRootRoute({ + component: () => , + }) + + const homeRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: () =>
Home
, + }) + + const aboutRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/about', + component: () =>
About
, + }) + + const routeTree = rootRoute.addChildren([homeRoute, aboutRoute]) + + const router = createRouter({ + routeTree, + history: createMemoryHistory({ + initialEntries: ['/my-app/about'], + }), + rewrite: createBasepathRewrite('my-app'), + }) + + render() + + await waitFor(() => { + expect(screen.getByTestId('about')).toBeInTheDocument() + }) + + // Router should interpret the URL without the basepath + expect(router.state.location.pathname).toBe('/about') + }) + + it('should handle basepath with leading and trailing slashes', async () => { + const rootRoute = createRootRoute({ + component: () => , + }) + + const usersRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/users', + component: () =>
Users
, + }) + + const routeTree = rootRoute.addChildren([usersRoute]) + + const router = createRouter({ + routeTree, + history: createMemoryHistory({ + initialEntries: ['/api/v1/users'], + }), + rewrite: createBasepathRewrite('/api/v1/'), // With leading and trailing slashes + }) + + render() + + await waitFor(() => { + expect(screen.getByTestId('users')).toBeInTheDocument() + }) + + expect(router.state.location.pathname).toBe('/users') + }) + + it('should handle empty basepath gracefully', async () => { + const rootRoute = createRootRoute({ + component: () => , + }) + + const testRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/test', + component: () =>
Test
, + }) + + const routeTree = rootRoute.addChildren([testRoute]) + + const router = createRouter({ + routeTree, + history: createMemoryHistory({ + initialEntries: ['/test'], + }), + rewrite: createBasepathRewrite(''), // Empty basepath + }) + + render() + + await waitFor(() => { + expect(screen.getByTestId('test')).toBeInTheDocument() + }) + + expect(router.state.location.pathname).toBe('/test') + }) + + it('should combine basepath with additional fromURL rewrite logic', async () => { + const rootRoute = createRootRoute({ + component: () => , + }) + + const newApiRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/api/v2', + component: () =>
API v2
, + }) + + const routeTree = rootRoute.addChildren([newApiRoute]) + + const router = createRouter({ + routeTree, + history: createMemoryHistory({ + initialEntries: ['/my-app/legacy/api/v1'], + }), + rewrite: createBasepathRewrite('my-app', { + // Additional rewrite logic after basepath removal + fromURL: ({ url }) => { + if (url.pathname === '/legacy/api/v1') { + url.pathname = '/api/v2' + return url + } + return undefined + }, + }), + }) + + render() + + await waitFor(() => { + expect(screen.getByTestId('api-v2')).toBeInTheDocument() + }) + + // Should first remove basepath (/my-app/legacy/api/v1 -> /legacy/api/v1) + // Then apply additional rewrite (/legacy/api/v1 -> /api/v2) + expect(router.state.location.pathname).toBe('/api/v2') + }) + + it('should handle complex basepath with subdomain-style paths', async () => { + const rootRoute = createRootRoute({ + component: () => , + }) + + const dashboardRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/dashboard', + component: () =>
Dashboard
, + }) + + const routeTree = rootRoute.addChildren([dashboardRoute]) + + const router = createRouter({ + routeTree, + history: createMemoryHistory({ + initialEntries: ['/tenant-123/dashboard'], + }), + rewrite: createBasepathRewrite('tenant-123'), + }) + + render() + + await waitFor(() => { + expect(screen.getByTestId('dashboard')).toBeInTheDocument() + }) + + expect(router.state.location.pathname).toBe('/dashboard') + }) + + it('should preserve search params and hash when rewriting basepath', async () => { + const rootRoute = createRootRoute({ + component: () => , + }) + + const searchRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/search', + component: () =>
Search
, + }) + + const routeTree = rootRoute.addChildren([searchRoute]) + + const router = createRouter({ + routeTree, + history: createMemoryHistory({ + initialEntries: ['/app/search?q=test&filter=all#results'], + }), + rewrite: createBasepathRewrite('app'), + }) + + render() + + await waitFor(() => { + expect(screen.getByTestId('search')).toBeInTheDocument() + }) + + expect(router.state.location.pathname).toBe('/search') + expect(router.state.location.search).toEqual({ + q: 'test', + filter: 'all', + }) + expect(router.state.location.hash).toBe('results') + }) + + it.skip('should handle nested basepath with multiple rewrite layers (complex case)', async () => { + const rootRoute = createRootRoute({ + component: () => , + }) + + const finalRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/final', + component: () =>
Final
, + }) + + const routeTree = rootRoute.addChildren([finalRoute]) + + const router = createRouter({ + routeTree, + history: createMemoryHistory({ + initialEntries: ['/base/legacy/old/path'], + }), + rewrite: createBasepathRewrite('base', { + fromURL: ({ url }) => { + // First layer: convert legacy paths + if (url.pathname === '/legacy/old/path') { + url.pathname = '/new/path' + return url + } + return undefined + }, + }), + }) + + // Add a second rewrite layer + const originalRewrite = router.options.rewrite + router.options.rewrite = { + fromURL: ({ url }) => { + // Apply basepath rewrite first + const result = originalRewrite?.fromURL?.({ url }) + if (result && typeof result !== 'string') { + // Second layer: convert new paths to final + if (result.pathname === '/new/path') { + result.pathname = '/final' + return result + } + } + return result + }, + } + + render() + + await waitFor(() => { + expect(screen.getByTestId('final')).toBeInTheDocument() + }) + + // Should apply: /base/legacy/old/path -> /legacy/old/path -> /new/path -> /final + expect(router.state.location.pathname).toBe('/final') + }) + + it.skip('should handle basepath with toURL rewriting (PENDING: toURL not implemented)', async () => { + // This test would verify that basepath is added back when navigating + // Currently skipped as toURL functionality is not working as expected + + const rootRoute = createRootRoute({ + component: () => , + }) + + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: () => ( +
+ + About + +
+ ), + }) + + const aboutRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/about', + component: () =>
About
, + }) + + const routeTree = rootRoute.addChildren([indexRoute, aboutRoute]) + + const history = createMemoryHistory({ initialEntries: ['/my-app/'] }) + + const router = createRouter({ + routeTree, + history, + rewrite: createBasepathRewrite('my-app'), + }) + + render() + + const aboutLink = await screen.findByTestId('about-link') + + await act(() => { + fireEvent.click(aboutLink) + }) + + await waitFor(() => { + expect(screen.getByTestId('about')).toBeInTheDocument() + }) + + // Router internal state should show clean path + expect(router.state.location.pathname).toBe('/about') + + // EXPECTED: History should show path with basepath added back + // ACTUAL: Currently fails due to toURL not being implemented + expect(history.location.pathname).toBe('/my-app/about') + }) +}) diff --git a/packages/router-core/src/index.ts b/packages/router-core/src/index.ts index 69ec837b7e1..d808d753c1a 100644 --- a/packages/router-core/src/index.ts +++ b/packages/router-core/src/index.ts @@ -103,7 +103,6 @@ export { parsePathname, interpolatePath, matchPathname, - removeBasepath, matchByPath, } from './path' export type { Segment } from './path' @@ -430,3 +429,5 @@ export { } from './ssr/serializer/transformer' export { defaultSerovalPlugins } from './ssr/serializer/seroval-plugins' + +export { rewriteBasepath } from './rewriteBasepath' diff --git a/packages/router-core/src/location.ts b/packages/router-core/src/location.ts index 9fe4e6f17d4..0f0861a2272 100644 --- a/packages/router-core/src/location.ts +++ b/packages/router-core/src/location.ts @@ -3,15 +3,12 @@ import type { AnySchema } from './validators' export interface ParsedLocation { /** - * The URL instance representing the location. In future versions of TanStack Router, - * this will be the preferred way to access basic url information. This will - * not include derived properties like `fullPath`, the `search` object, - * `maskedLocation`, or anything else that can already be found on the `URL` class. + * @description The public href of the location, including the origin before any rewrites. + * If a rewrite is applied, the `href` property will be the rewritten URL. */ - url: URL + publicHref: string /** - * @deprecated In future versions of TanStack Router, `href` will - * represent the full URL, including the origin. As a replacement, + * @description The full URL of the location, including the origin. As a replacement, * please upgrade to the new `fullPath` property, which is derived by * combining `pathname`, `search`, and `hash`. like so: * `${pathname}${searchStr}${hash}`. If you're looking for the actual @@ -25,9 +22,7 @@ export interface ParsedLocation { */ fullPath: string /** - * @deprecated In future versions of TanStack Router, this property will be removed. - * Please use the `location.url.pathname` property instead. - * The pathname of the location, including the leading slash. + * @description The pathname of the location, including the leading slash. */ pathname: string /** diff --git a/packages/router-core/src/path.ts b/packages/router-core/src/path.ts index 827f17c2432..42466128e49 100644 --- a/packages/router-core/src/path.ts +++ b/packages/router-core/src/path.ts @@ -97,7 +97,7 @@ export function exactPathTest( // /a/b/c + d/ = /a/b/c/d // /a/b/c + d/e = /a/b/c/d/e interface ResolvePathOptions { - basepath: string + // basepath: string base: string to: string trailingSlash?: 'always' | 'never' | 'preserve' @@ -151,16 +151,12 @@ function segmentToString(segment: Segment): string { } export function resolvePath({ - basepath, base, to, trailingSlash = 'never', - caseSensitive, + // caseSensitive, parseCache, }: ResolvePathOptions) { - base = removeBasepath(basepath, base, caseSensitive) - to = removeBasepath(basepath, to, caseSensitive) - let baseSegments = parsePathname(base, parseCache).slice() const toSegments = parsePathname(to, parseCache) @@ -201,7 +197,8 @@ export function resolvePath({ } const segmentValues = baseSegments.map(segmentToString) - const joined = joinPaths([basepath, ...segmentValues]) + // const joined = joinPaths([basepath, ...segmentValues]) + const joined = joinPaths(segmentValues) return joined } @@ -491,17 +488,11 @@ function encodePathParam(value: string, decodeCharMap?: Map) { } export function matchPathname( - basepath: string, currentPathname: string, matchLocation: Pick, parseCache?: ParsePathnameCache, ): AnyPathParams | undefined { - const pathParams = matchByPath( - basepath, - currentPathname, - matchLocation, - parseCache, - ) + const pathParams = matchByPath(currentPathname, matchLocation, parseCache) // const searchMatched = matchBySearch(location.search, matchLocation) if (matchLocation.to && !pathParams) { @@ -511,49 +502,7 @@ export function matchPathname( return pathParams ?? {} } -export function removeBasepath( - basepath: string, - pathname: string, - caseSensitive: boolean = false, -) { - // normalize basepath and pathname for case-insensitive comparison if needed - const normalizedBasepath = caseSensitive ? basepath : basepath.toLowerCase() - const normalizedPathname = caseSensitive ? pathname : pathname.toLowerCase() - - switch (true) { - // default behaviour is to serve app from the root - pathname - // left untouched - case normalizedBasepath === '/': - return pathname - - // shortcut for removing the basepath if it matches the pathname - case normalizedPathname === normalizedBasepath: - return '' - - // in case pathname is shorter than basepath - there is - // nothing to remove - case pathname.length < basepath.length: - return pathname - - // avoid matching partial segments - strict equality handled - // earlier, otherwise, basepath separated from pathname with - // separator, therefore lack of separator means partial - // segment match (`/app` should not match `/application`) - case normalizedPathname[normalizedBasepath.length] !== '/': - return pathname - - // remove the basepath from the pathname if it starts with it - case normalizedPathname.startsWith(normalizedBasepath): - return pathname.slice(basepath.length) - - // otherwise, return the pathname as is - default: - return pathname - } -} - export function matchByPath( - basepath: string, from: string, { to, @@ -562,14 +511,7 @@ export function matchByPath( }: Pick, parseCache?: ParsePathnameCache, ): Record | undefined { - // check basepath first - if (basepath !== '/' && !from.startsWith(basepath)) { - return undefined - } - // Remove the base path from the pathname - from = removeBasepath(basepath, from, caseSensitive) - // Default to to $ (wildcard) - to = removeBasepath(basepath, `${to ?? '$'}`, caseSensitive) + const stringTo = to as string // Parse the from and to const baseSegments = parsePathname( @@ -577,7 +519,7 @@ export function matchByPath( parseCache, ) const routeSegments = parsePathname( - to.startsWith('/') ? to : `/${to}`, + stringTo.startsWith('/') ? stringTo : `/${stringTo}`, parseCache, ) diff --git a/packages/router-core/src/rewriteBasepath.ts b/packages/router-core/src/rewriteBasepath.ts new file mode 100644 index 00000000000..570ce27c8b7 --- /dev/null +++ b/packages/router-core/src/rewriteBasepath.ts @@ -0,0 +1,27 @@ +import { joinPaths, trimPath } from './path' +import type { LocationRewrite } from './router' + +export function rewriteBasepath( + basepath: string, + rewrite?: LocationRewrite, + opts?: { + caseSensitive?: boolean + }, +): LocationRewrite { + const trimmedBasepath = trimPath(basepath) + return { + fromHref: ({ href }) => { + const url = new URL(href) + url.pathname = url.pathname.replace( + new RegExp(`^/${trimmedBasepath}/`, opts?.caseSensitive ? '' : 'i'), + '/', + ) + return rewrite?.fromHref ? rewrite.fromHref({ href: url.href }) : url.href + }, + toHref: ({ href }) => { + const url = new URL(href) + url.pathname = joinPaths(['/', trimmedBasepath, url.pathname]) + return rewrite?.toHref ? rewrite.toHref({ href: url.href }) : url.href + }, + } +} diff --git a/packages/router-core/src/router.ts b/packages/router-core/src/router.ts index 62a38d0150e..eefa92d044c 100644 --- a/packages/router-core/src/router.ts +++ b/packages/router-core/src/router.ts @@ -19,7 +19,6 @@ import { SEGMENT_TYPE_WILDCARD, cleanPath, interpolatePath, - joinPaths, matchPathname, parsePathname, resolvePath, @@ -34,6 +33,7 @@ import { rootRouteId } from './root' import { isRedirect, redirect } from './redirect' import { createLRUCache } from './lru-cache' import { loadMatches, loadRouteChunk, routeNeedsPreload } from './load-matches' +import { rewriteBasepath } from './rewriteBasepath' import type { ParsePathnameCache, Segment } from './path' import type { SearchParser, SearchSerializer } from './searchParams' import type { AnyRedirect, ResolvedRedirect } from './redirect' @@ -266,6 +266,18 @@ export interface RouterOptions< /** * The basepath for then entire router. This is useful for mounting a router instance at a subpath. * + * @deprecated - use `rewrite.fromURL` with the new `rewriteBasepath` utility instead: + * ```ts + * const router = createRouter({ + * routeTree, + * rewrite: rewriteBasepath('/basepath') + * // Or wrap existing rewrite functionality + * rewrite: rewriteBasepath('/basepath', { + * toURL: ({ url }) => {...}, + * fromURL: ({ url }) => {...}, + * }) + * }) + * ``` * @default '/' * @link [API Docs](https://tanstack.com/router/latest/docs/framework/react/api/router/RouterOptionsType#basepath-property) */ @@ -431,28 +443,44 @@ export interface RouterOptions< serializationAdapters?: TSerializationAdapters /** - * Configures how the router will rewrite the location between the actual URL and the internal URL of the router. + * Configures how the router will rewrite the location between the actual href and the internal href of the router. * * @default undefined + * @description You can provide a custom rewrite pair (in/out) or use the utilities like `rewriteBasepath` as a convenience for common use cases, or even do both! + * This is useful for basepath rewriting, shifting data from the origin to the path (for things like ) */ - rewrite?: { - /** - * A function that will be called to rewrite the URL before it is interpreted by the router from the history instance. - * - * @default undefined - * @returns The rewritten URL or undefined if no rewrite is needed. - */ - fromURL?: ({ url }: { url: URL }) => undefined | URL | string - /** - * A function that will be called to rewrite the URL before it is committed to the actual history instance from the router. - * - * @default undefined - * @returns The rewritten URL or undefined if no rewrite is needed. - */ - toURL?: ({ url }: { url: URL }) => undefined | URL | string - } + rewrite?: LocationRewrite } +export type LocationRewrite = { + /** + * A function that will be called to rewrite the URL before it is interpreted by the router from the history instance. + * Utilities like `rewriteBasepath` are provided as a convenience for common use cases. + * + * @default undefined + */ + fromHref?: LocationRewriteFunction + /** + * A function that will be called to rewrite the URL before it is committed to the actual history instance from the router. + * Utilities like `rewriteBasepath` are provided as a convenience for common use cases. + * + * @default undefined + */ + toHref?: LocationRewriteFunction +} + +/** + * A function that will be called to rewrite the URL. + * + * @param url The URL to rewrite. + * @returns The rewritten URL (as a URL instance or full href string) or undefined if no rewrite is needed. + */ +export type LocationRewriteFunction = ({ + href, +}: { + href: string +}) => undefined | string + export interface RouterState< in out TRouteTree extends AnyRoute = AnyRoute, in out TRouteMatch = MakeRouteMatchUnion, @@ -845,6 +873,7 @@ export class RouterCore< > history!: TRouterHistory latestLocation!: ParsedLocation> + // @deprecated - basepath functionality is now implemented via the `rewrite` option basepath!: string routeTree!: TRouteTree routesById!: RoutesById @@ -912,7 +941,6 @@ export class RouterCore< ) } - const previousOptions = this.options this.options = { ...this.options, ...newOptions, @@ -929,21 +957,6 @@ export class RouterCore< ) : undefined - if ( - !this.basepath || - (newOptions.basepath && newOptions.basepath !== previousOptions.basepath) - ) { - if ( - newOptions.basepath === undefined || - newOptions.basepath === '' || - newOptions.basepath === '/' - ) { - this.basepath = '/' - } else { - this.basepath = `/${trimPath(newOptions.basepath)}` - } - } - if ( !this.history || (this.options.history && this.options.history !== this.history) @@ -952,7 +965,7 @@ export class RouterCore< this.options.history ?? ((this.isServer ? createMemoryHistory({ - initialEntries: [this.basepath || '/'], + initialEntries: ['/'], }) : createBrowserHistory()) as TRouterHistory) this.updateLatestLocation() @@ -1050,37 +1063,43 @@ export class RouterCore< previousLocation, ) => { const parse = ({ - url, + href: publicHref, state, }: HistoryLocation): ParsedLocation> => { + // For backwards compatibility, we support a basepath option, which we now implement as a rewrite + let href = publicHref + + if (this.options.basepath) { + href = + rewriteBasepath(this.options.basepath).fromHref?.({ href }) || href + } + // Before we do any processing, we need to allow rewrites to modify the URL - if (this.options.rewrite?.fromURL) { - url = new URL(this.options.rewrite.fromURL({ url }) || url) + if (this.options.rewrite?.fromHref) { + href = this.options.rewrite.fromHref({ href }) || href } // Make sure we derive all the properties we need from the URL object now // (These used to come from the history location object, but won't in v2) - - const { pathname, search, hash } = url - - const parsedSearch = this.options.parseSearch(search) + const url = new URL(href) + const parsedSearch = this.options.parseSearch(url.search) const searchStr = this.options.stringifySearch(parsedSearch) - // Make sure our final url uses the re-stringified pathname, search, and has for consistency // (We were already doing this, so just keeping it for now) url.search = searchStr const fullPath = url.href.replace(url.origin, '') + const { pathname, hash } = url + return { - url, + publicHref: href, + href, fullPath, pathname, searchStr, search: replaceEqualDeep(previousLocation?.search, parsedSearch) as any, hash: hash.split('#').reverse()[0] ?? '', - // TODO: v2 needs to supply the actual href (full URL) instead of the fullPath - href: fullPath, state: replaceEqualDeep(previousLocation?.state, state), } } @@ -1108,7 +1127,6 @@ export class RouterCore< resolvePathWithBase = (from: string, path: string) => { const resolvedPath = resolvePath({ - basepath: this.basepath, base: from, to: cleanPath(path), trailingSlash: this.options.trailingSlash, @@ -1344,7 +1362,7 @@ export class RouterCore< ? replaceEqualDeep(previousMatch.params, routeParams) : routeParams, _strictParams: usedParams, - pathname: joinPaths([this.basepath, interpolatedPath]), + pathname: interpolatedPath, updatedAt: Date.now(), search: previousMatch ? replaceEqualDeep(previousMatch.search, preMatchSearch) @@ -1449,7 +1467,6 @@ export class RouterCore< return getMatchedRoutes({ pathname, routePathname, - basepath: this.basepath, caseSensitive: this.options.caseSensitive, routesByPath: this.routesByPath, routesById: this.routesById, @@ -1641,28 +1658,38 @@ export class RouterCore< // Create the full path of the location const fullPath = `${nextPathname}${searchStr}${hashStr}` - // Create the URL object - let url = new URL(fullPath, currentLocation.url.origin) + // Create the new href with full origin + const href = new URL(fullPath, new URL(currentLocation.href).origin).href + + let publicHref = href // If a rewrite function is provided, use it to rewrite the URL - if (this.options.rewrite?.toURL) { - url = new URL(this.options.rewrite.toURL({ url }) || url) + if (this.options.rewrite?.toHref) { + publicHref = + this.options.rewrite.toHref({ href: publicHref }) || publicHref + } + + // For backwards compatibility, we support a basepath option, which we now implement as a rewrite + if (this.options.basepath) { + publicHref = + rewriteBasepath(this.options.basepath).toHref?.({ + href: publicHref, + }) || publicHref } // Lastly, allow the history type to modify the URL - url = new URL(this.history.createHref(url.toString())) + publicHref = this.history.createHref(publicHref) // Return the next location return { - url, + publicHref, + href, + fullPath, pathname: nextPathname, search: nextSearch, searchStr, state: nextState as any, hash: hash ?? '', - fullPath, - // TODO: v2 needs to supply the actual href (full URL) instead of the fullPath - href: fullPath, unmaskOnReload: dest.unmaskOnReload, } } @@ -1680,7 +1707,6 @@ export class RouterCore< const foundMask = this.options.routeMasks?.find((d) => { const match = matchPathname( - this.basepath, next.pathname, { to: d.from, @@ -1754,7 +1780,9 @@ export class RouterCore< return isEqual } - const isSameUrl = this.latestLocation.fullPath === next.fullPath + const isSameUrl = + trimPathRight(this.latestLocation.fullPath) === + trimPathRight(next.fullPath) const previousCommitPromise = this.commitLocationPromise this.commitLocationPromise = createControlledPromise(() => { @@ -1803,7 +1831,7 @@ export class RouterCore< this.shouldViewTransition = viewTransition this.history[next.replace ? 'replace' : 'push']( - nextHistory.href, + nextHistory.publicHref, nextHistory.state, { ignoreBlocker }, ) @@ -1865,7 +1893,7 @@ export class RouterCore< if (reloadDocument) { if (!href) { const location = this.buildLocation({ to, ...rest } as any) - href = location.url.href + href = location.href } if (rest.replace) { window.location.replace(href) @@ -1893,7 +1921,7 @@ export class RouterCore< if (this.isServer) { // for SPAs on the initial load, this is handled by the Transitioner const nextLocation = this.buildLocation({ - to: this.latestLocation.url.pathname, + to: this.latestLocation.pathname, search: true, params: true, hash: true, @@ -1915,9 +1943,10 @@ export class RouterCore< trimPath(normalizeUrl(this.latestLocation.fullPath)) !== trimPath(normalizeUrl(nextLocation.fullPath)) ) { - throw redirect({ href: nextLocation.url.href }) + throw redirect({ href: nextLocation.href }) } } + // Match the routes const pendingMatches = this.matchRoutes(this.latestLocation) @@ -2063,6 +2092,7 @@ export class RouterCore< this.latestLoadPromise = undefined this.commitLocationPromise = undefined } + resolve() }) }) @@ -2361,7 +2391,6 @@ export class RouterCore< : this.state.resolvedLocation || this.state.location const match = matchPathname( - this.basepath, baseLocation.pathname, { ...opts, @@ -2697,7 +2726,6 @@ export function processRouteTree({ export function getMatchedRoutes({ pathname, routePathname, - basepath, caseSensitive, routesByPath, routesById, @@ -2706,7 +2734,6 @@ export function getMatchedRoutes({ }: { pathname: string routePathname?: string - basepath: string caseSensitive?: boolean routesByPath: Record routesById: Record @@ -2717,7 +2744,6 @@ export function getMatchedRoutes({ const trimmedPath = trimPathRight(pathname) const getMatchedParams = (route: TRouteLike) => { const result = matchPathname( - basepath, trimmedPath, { to: route.fullPath, diff --git a/packages/router-core/tests/path.test.ts b/packages/router-core/tests/path.test.ts index 2a241bcb77d..7546f38f341 100644 --- a/packages/router-core/tests/path.test.ts +++ b/packages/router-core/tests/path.test.ts @@ -7,95 +7,12 @@ import { interpolatePath, matchPathname, parsePathname, - removeBasepath, removeTrailingSlash, resolvePath, trimPathLeft, } from '../src/path' import type { Segment as PathSegment } from '../src/path' -describe('removeBasepath', () => { - it.each([ - { - name: '`/` should leave pathname as-is', - basepath: '/', - pathname: '/path', - expected: '/path', - }, - { - name: 'should return empty string if basepath is the same as pathname', - basepath: '/path', - pathname: '/path', - expected: '', - }, - { - name: 'should remove basepath from the beginning of the pathname', - basepath: '/app', - pathname: '/app/path/app', - expected: '/path/app', - }, - { - name: 'should remove multisegment basepath from the beginning of the pathname', - basepath: '/app/new', - pathname: '/app/new/path/app/new', - expected: '/path/app/new', - }, - { - name: 'should remove basepath only in case it matches segments completely', - basepath: '/app', - pathname: '/application', - expected: '/application', - }, - { - name: 'should remove multisegment basepath only in case it matches segments completely', - basepath: '/app/new', - pathname: '/app/new-application', - expected: '/app/new-application', - }, - ])('$name', ({ basepath, pathname, expected }) => { - expect(removeBasepath(basepath, pathname)).toBe(expected) - }) - describe('case sensitivity', () => { - describe('caseSensitive = true', () => { - it.each([ - { - name: 'should not remove basepath from the beginning of the pathname', - basepath: '/app', - pathname: '/App/path/App', - expected: '/App/path/App', - }, - { - name: 'should not remove basepath from the beginning of the pathname with multiple segments', - basepath: '/app/New', - pathname: '/App/New/path/App', - expected: '/App/New/path/App', - }, - ])('$name', ({ basepath, pathname, expected }) => { - expect(removeBasepath(basepath, pathname, true)).toBe(expected) - }) - }) - - describe('caseSensitive = false', () => { - it.each([ - { - name: 'should remove basepath from the beginning of the pathname', - basepath: '/App', - pathname: '/app/path/app', - expected: '/path/app', - }, - { - name: 'should remove multisegment basepath from the beginning of the pathname', - basepath: '/App/New', - pathname: '/app/new/path/app', - expected: '/path/app', - }, - ])('$name', ({ basepath, pathname, expected }) => { - expect(removeBasepath(basepath, pathname, false)).toBe(expected) - }) - }) - }) -}) - describe.each([{ basepath: '/' }, { basepath: '/app' }, { basepath: '/app/' }])( 'removeTrailingSlash with basepath $basepath', ({ basepath }) => { @@ -167,39 +84,32 @@ describe.each([{ basepath: '/' }, { basepath: '/app' }, { basepath: '/app/' }])( describe('resolvePath', () => { describe.each([ - ['/', '/', '/', '/'], - ['/', '/', '/a', '/a'], - ['/', '/', 'a/', '/a'], - ['/', '/', '/a/b', '/a/b'], - ['/', 'a', 'b', '/a/b'], - ['/a/b', 'c', '/a/b/c', '/a/b/c'], - ['/a/b', '/', 'c', '/a/b/c'], - ['/a/b', '/', './c', '/a/b/c'], - ['/', '/', 'a/b', '/a/b'], - ['/', '/', './a/b', '/a/b'], - ['/', '/a/b/c', 'd', '/a/b/c/d'], - ['/', '/a/b/c', './d', '/a/b/c/d'], - ['/', '/a/b/c', './../d', '/a/b/d'], - ['/', '/a/b/c/d', './../d', '/a/b/c/d'], - ['/', '/a/b/c', '../../d', '/a/d'], - ['/', '/a/b/c', '../d', '/a/b/d'], - ['/', '/a/b/c', '..', '/a/b'], - ['/', '/a/b/c', '../..', '/a'], - ['/', '/a/b/c', '../../..', '/'], - ['/', '/a/b/c/', '../../..', '/'], - ['/products', '/', '/products-list', '/products/products-list'], - ['/basepath', '/products', '.', '/basepath/products'], - ])('resolves correctly', (base, a, b, eq) => { - it(`Base: ${base} - ${a} to ${b} === ${eq}`, () => { - expect(resolvePath({ basepath: base, base: a, to: b })).toEqual(eq) + ['/', '/', '/'], + ['/', '/a', '/a'], + ['/', 'a/', '/a'], + ['/', '/a/b', '/a/b'], + ['a', 'b', '/a/b'], + ['/', 'a/b', '/a/b'], + ['/', './a/b', '/a/b'], + ['/a/b/c', 'd', '/a/b/c/d'], + ['/a/b/c', './d', '/a/b/c/d'], + ['/a/b/c', './../d', '/a/b/d'], + ['/a/b/c/d', './../d', '/a/b/c/d'], + ['/a/b/c', '../../d', '/a/d'], + ['/a/b/c', '../d', '/a/b/d'], + ['/a/b/c', '..', '/a/b'], + ['/a/b/c', '../..', '/a'], + ['/a/b/c', '../../..', '/'], + ['/a/b/c/', '../../..', '/'], + ])('resolves correctly', (a, b, eq) => { + it(`${a} to ${b} === ${eq}`, () => { + expect(resolvePath({ base: a, to: b })).toEqual(eq) }) - it(`Base: ${base} - ${a}/ to ${b} === ${eq} (trailing slash)`, () => { - expect(resolvePath({ basepath: base, base: a + '/', to: b })).toEqual(eq) + it(`${a}/ to ${b} === ${eq} (trailing slash)`, () => { + expect(resolvePath({ base: a + '/', to: b })).toEqual(eq) }) - it(`Base: ${base} - ${a}/ to ${b}/ === ${eq} (trailing slash + trailing slash)`, () => { - expect( - resolvePath({ basepath: base, base: a + '/', to: b + '/' }), - ).toEqual(eq) + it(`${a}/ to ${b}/ === ${eq} (trailing slash + trailing slash)`, () => { + expect(resolvePath({ base: a + '/', to: b + '/' })).toEqual(eq) }) }) describe('trailingSlash', () => { @@ -207,7 +117,6 @@ describe('resolvePath', () => { it('keeps trailing slash', () => { expect( resolvePath({ - basepath: '/', base: '/a/b/c', to: 'd/', trailingSlash: 'always', @@ -217,7 +126,6 @@ describe('resolvePath', () => { it('adds trailing slash', () => { expect( resolvePath({ - basepath: '/', base: '/a/b/c', to: 'd', trailingSlash: 'always', @@ -229,7 +137,6 @@ describe('resolvePath', () => { it('removes trailing slash', () => { expect( resolvePath({ - basepath: '/', base: '/a/b/c', to: 'd/', trailingSlash: 'never', @@ -239,7 +146,6 @@ describe('resolvePath', () => { it('does not add trailing slash', () => { expect( resolvePath({ - basepath: '/', base: '/a/b/c', to: 'd', trailingSlash: 'never', @@ -251,7 +157,6 @@ describe('resolvePath', () => { it('keeps trailing slash', () => { expect( resolvePath({ - basepath: '/', base: '/a/b/c', to: 'd/', trailingSlash: 'preserve', @@ -261,7 +166,6 @@ describe('resolvePath', () => { it('does not add trailing slash', () => { expect( resolvePath({ - basepath: '/', base: '/a/b/c', to: 'd', trailingSlash: 'preserve', @@ -294,7 +198,6 @@ describe('resolvePath', () => { const candidate = base + trimPathLeft(to) expect( resolvePath({ - basepath: '/', base, to: candidate, trailingSlash: 'never', @@ -324,7 +227,6 @@ describe('resolvePath', () => { const candidate = base + trimPathLeft(to) expect( resolvePath({ - basepath: '/', base, to: candidate, trailingSlash: 'never', @@ -621,7 +523,7 @@ describe('matchPathname', () => { ])( '$name', ({ basepath, input, matchingOptions, expectedMatchedParams }) => { - expect(matchPathname(basepath, input, matchingOptions)).toStrictEqual( + expect(matchPathname(input, matchingOptions)).toStrictEqual( expectedMatchedParams, ) }, @@ -703,7 +605,7 @@ describe('matchPathname', () => { }, }, ])('$name', ({ input, matchingOptions, expectedMatchedParams }) => { - expect(matchPathname('/', input, matchingOptions)).toStrictEqual( + expect(matchPathname(input, matchingOptions)).toStrictEqual( expectedMatchedParams, ) }) @@ -767,7 +669,7 @@ describe('matchPathname', () => { }, }, ])('$name', ({ input, matchingOptions, expectedMatchedParams }) => { - expect(matchPathname('/', input, matchingOptions)).toStrictEqual( + expect(matchPathname(input, matchingOptions)).toStrictEqual( expectedMatchedParams, ) }) @@ -826,7 +728,7 @@ describe('matchPathname', () => { }, }, ])('$name', ({ input, matchingOptions, expectedMatchedParams }) => { - expect(matchPathname('/', input, matchingOptions)).toStrictEqual( + expect(matchPathname(input, matchingOptions)).toStrictEqual( expectedMatchedParams, ) }) diff --git a/packages/solid-router/src/index.tsx b/packages/solid-router/src/index.tsx index 91d92623c72..bd6e1dfd4bd 100644 --- a/packages/solid-router/src/index.tsx +++ b/packages/solid-router/src/index.tsx @@ -11,7 +11,6 @@ export { parsePathname, interpolatePath, matchPathname, - removeBasepath, matchByPath, rootRouteId, defaultSerializeError, @@ -354,3 +353,4 @@ export { ScriptOnce } from './ScriptOnce' export { Asset } from './Asset' export { HeadContent, useTags } from './HeadContent' export { Scripts } from './Scripts' +export { rewriteBasepath } from '@tanstack/router-core' diff --git a/packages/start-server-core/src/createStartHandler.ts b/packages/start-server-core/src/createStartHandler.ts index b401284d940..59de97a0163 100644 --- a/packages/start-server-core/src/createStartHandler.ts +++ b/packages/start-server-core/src/createStartHandler.ts @@ -10,6 +10,7 @@ import { isResolvedRedirect, joinPaths, processRouteTree, + rewriteBasepath, trimPath, } from '@tanstack/router-core' import { attachRouterServerSsrUtils } from '@tanstack/router-core/ssr/server' @@ -334,12 +335,16 @@ async function handleServerRoutes(opts: { basePath: string executeRouter: () => Promise }) { - const url = new URL(opts.request.url) - const pathname = url.pathname + let href = new URL(opts.request.url).href + + if (opts.basePath) { + href = rewriteBasepath(opts.basePath).fromHref?.({ href }) || href + } + + const pathname = new URL(href).pathname const serverTreeResult = getMatchedRoutes({ pathname, - basepath: opts.basePath, caseSensitive: true, routesByPath: opts.processedServerRouteTree.routesByPath, routesById: opts.processedServerRouteTree.routesById, From 2ea6460715fec5eb9560c827cf2db028de05faee Mon Sep 17 00:00:00 2001 From: Tanner Linsley Date: Sat, 30 Aug 2025 14:46:12 -0600 Subject: [PATCH 34/34] add import --- packages/router-core/src/router.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/router-core/src/router.ts b/packages/router-core/src/router.ts index eefa92d044c..35ccd4d9e04 100644 --- a/packages/router-core/src/router.ts +++ b/packages/router-core/src/router.ts @@ -8,6 +8,7 @@ import invariant from 'tiny-invariant' import { createControlledPromise, deepEqual, + findLast, functionalUpdate, last, replaceEqualDeep,