Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
91 changes: 91 additions & 0 deletions packages/react-router/tests/link.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
} from '@testing-library/react'

import { z } from 'zod'
import { trailingSlashOptions } from '@tanstack/router-core'
import {
Link,
Outlet,
Expand Down Expand Up @@ -5549,6 +5550,96 @@ describe.each([{ basepath: '' }, { basepath: '/basepath' }])(
},
)

describe('splat routes with empty splat', () => {
test.each(Object.values(trailingSlashOptions))(
'should handle empty _splat parameter with trailingSlash: %s',
async (trailingSlash) => {
const tail = trailingSlash === 'always' ? '/' : ''

const rootRoute = createRootRoute()
const indexRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/',
component: () => {
return (
<>
<h1>Index Route</h1>
<Link
data-testid="splat-link-with-empty-splat"
to="/splat/$"
params={{ _splat: '' }}
activeProps={{ className: 'active' }}
>
Link to splat with _splat value
</Link>
<Link
data-testid="splat-link-with-undefined-splat"
to="/splat/$"
params={{ _splat: undefined }}
activeProps={{ className: 'active' }}
>
Link to splat with undefined _splat
</Link>
<Link
data-testid="splat-link-with-no-splat"
to="/splat/$"
params={{}}
activeProps={{ className: 'active' }}
>
Link to splat with no _splat at all
</Link>
</>
)
},
})

const splatRoute = createRoute({
getParentRoute: () => rootRoute,
path: 'splat/$',
component: () => {
return <h1>Splat Route</h1>
},
})

const router = createRouter({
routeTree: rootRoute.addChildren([indexRoute, splatRoute]),
history,
trailingSlash,
})

render(<RouterProvider router={router} />)

const splatLinkWithEmptySplat = await screen.findByTestId(
'splat-link-with-empty-splat',
)
const splatLinkWithUndefinedSplat = await screen.findByTestId(
'splat-link-with-undefined-splat',
)
const splatLinkWithNoSplat = await screen.findByTestId(
'splat-link-with-no-splat',
)

// When _splat has a value, it should follow the trailingSlash setting
expect(splatLinkWithEmptySplat.getAttribute('href')).toBe(`/splat${tail}`)
expect(splatLinkWithUndefinedSplat.getAttribute('href')).toBe(
`/splat${tail}`,
)
expect(splatLinkWithNoSplat.getAttribute('href')).toBe(`/splat${tail}`)

// Click the link with empty _splat and ensure the route matches
await act(async () => {
fireEvent.click(splatLinkWithEmptySplat)
})

expect(splatLinkWithEmptySplat).toHaveClass('active')
expect(splatLinkWithUndefinedSplat).toHaveClass('active')
expect(splatLinkWithNoSplat).toHaveClass('active')
expect(window.location.pathname).toBe(`/splat${tail}`)
expect(await screen.findByText('Splat Route')).toBeInTheDocument()
},
)
})

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',
Expand Down
55 changes: 55 additions & 0 deletions packages/react-router/tests/navigate.test.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { afterEach, describe, expect, it, vi } from 'vitest'

import { trailingSlashOptions } from '@tanstack/router-core'
import {
createMemoryHistory,
createRootRoute,
Expand Down Expand Up @@ -1238,3 +1239,57 @@ describe('router.navigate navigation using optional path parameters - edge cases
expect(router.state.location.pathname).toBe('/files/prefix.txt')
})
})

describe('splat routes with empty splat', () => {
it.each(Object.values(trailingSlashOptions))(
'should handle empty _splat parameter with trailingSlash: %s',
async (trailingSlash) => {
const tail = trailingSlash === 'always' ? '/' : ''

const history = createMemoryHistory({ initialEntries: ['/'] })

const rootRoute = createRootRoute()
const indexRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/',
})

const splatRoute = createRoute({
getParentRoute: () => rootRoute,
path: 'splat/$',
})

const router = createRouter({
routeTree: rootRoute.addChildren([indexRoute, splatRoute]),
history,
trailingSlash,
})

await router.load()

// All of these route params should navigate to the same location
const paramSets = [
{
_splat: '',
},
{
_splat: undefined,
},
{},
]

for (const params of paramSets) {
await router.navigate({
to: '/splat/$',
params,
})
await router.invalidate()

expect(router.state.location.pathname).toBe(`/splat${tail}`)
// Navigate back to index
await router.navigate({ to: '/' })
await router.invalidate()
}
},
)
})
123 changes: 123 additions & 0 deletions packages/react-router/tests/useNavigate.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ import {
} from '@testing-library/react'

import { z } from 'zod'

import { trailingSlashOptions } from '@tanstack/router-core'
import {
Navigate,
Outlet,
Expand Down Expand Up @@ -2555,3 +2557,124 @@ describe.each([{ basepath: '' }, { basepath: '/basepath' }])(
})
},
)

describe('splat routes with empty splat', () => {
test.each(Object.values(trailingSlashOptions))(
'should handle empty _splat parameter with trailingSlash: %s',
async (trailingSlash) => {
const tail = trailingSlash === 'always' ? '/' : ''

const rootRoute = createRootRoute()
const indexRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/',
component: function IndexComponent() {
const navigate = useNavigate()
return (
<>
<h1>Index Route</h1>
<button
data-testid="splat-btn-with-empty-splat"
onClick={() =>
navigate({
to: '/splat/$',
params: { _splat: '' },
})
}
type="button"
>
Navigate to splat with empty _splat
</button>
<button
data-testid="splat-btn-with-undefined-splat"
onClick={() =>
navigate({
to: '/splat/$',
params: { _splat: undefined },
})
}
type="button"
>
Navigate to splat with undefined _splat
</button>
<button
data-testid="splat-btn-with-no-splat"
onClick={() =>
navigate({
to: '/splat/$',
params: {},
})
}
type="button"
>
Navigate to splat with no _splat
</button>
</>
)
},
})

const splatRoute = createRoute({
getParentRoute: () => rootRoute,
path: 'splat/$',
component: () => {
return <h1>Splat Route</h1>
},
})

const router = createRouter({
routeTree: rootRoute.addChildren([indexRoute, splatRoute]),
history,
trailingSlash,
})

render(<RouterProvider router={router} />)

// Navigate with empty _splat
const splatBtnWithEmptySplat = await screen.findByTestId(
'splat-btn-with-empty-splat',
)

await act(async () => {
fireEvent.click(splatBtnWithEmptySplat)
})

expect(window.location.pathname).toBe(`/splat${tail}`)
expect(await screen.findByText('Splat Route')).toBeInTheDocument()

// Navigate back to index
await act(async () => {
history.push('/')
})

// Navigate with undefined _splat
const splatBtnWithUndefinedSplat = await screen.findByTestId(
'splat-btn-with-undefined-splat',
)

await act(async () => {
fireEvent.click(splatBtnWithUndefinedSplat)
})

expect(window.location.pathname).toBe(`/splat${tail}`)
expect(await screen.findByText('Splat Route')).toBeInTheDocument()

// Navigate back to index
await act(async () => {
history.push('/')
})

// Navigate with no _splat
const splatBtnWithNoSplat = await screen.findByTestId(
'splat-btn-with-no-splat',
)

await act(async () => {
fireEvent.click(splatBtnWithNoSplat)
})

expect(window.location.pathname).toBe(`/splat${tail}`)
expect(await screen.findByText('Splat Route')).toBeInTheDocument()
},
)
})
2 changes: 2 additions & 0 deletions packages/router-core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -204,7 +204,9 @@ export {
PathParamError,
getInitialRouterState,
getMatchedRoutes,
trailingSlashOptions,
} from './router'

export type {
ViewTransitionOptions,
TrailingSlashOption,
Expand Down
4 changes: 2 additions & 2 deletions packages/router-core/src/path.ts
Original file line number Diff line number Diff line change
Expand Up @@ -426,8 +426,8 @@ export function interpolatePath({
const segmentPrefix = segment.prefixSegment || ''
const segmentSuffix = segment.suffixSegment || ''

// Check if _splat parameter is missing
if (!('_splat' in params)) {
// Check if _splat parameter is missing. _splat could be missing if undefined or an empty string or some other falsy value.
if (!params._splat) {
isMissingParams = true
// For missing splat parameters, just return the prefix and suffix without the wildcard
if (leaveWildcards) {
Expand Down
9 changes: 8 additions & 1 deletion packages/router-core/src/router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -797,7 +797,14 @@ export function defaultSerializeError(err: unknown) {
}
}

export type TrailingSlashOption = 'always' | 'never' | 'preserve'
export const trailingSlashOptions = {
always: 'always',
never: 'never',
preserve: 'preserve',
} as const

export type TrailingSlashOption =
(typeof trailingSlashOptions)[keyof typeof trailingSlashOptions]

export function getLocationChangeInfo(routerState: {
resolvedLocation?: ParsedLocation
Expand Down
16 changes: 16 additions & 0 deletions packages/router-core/tests/path.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -472,6 +472,22 @@ describe('interpolatePath', () => {
params: {},
expectedResult: '/hello/prefixsuffix',
},
{
name: 'splat route with empty splat',
path: '/hello/$',
params: {
_splat: '',
},
expectedResult: '/hello',
},
{
name: 'splat route with undefined splat',
path: '/hello/$',
params: {
_splat: undefined,
},
expectedResult: '/hello',
},
])('$name', ({ path, params, expectedResult }) => {
const result = interpolatePath({
path,
Expand Down
Loading
Loading