Skip to content
Merged
94 changes: 94 additions & 0 deletions packages/react-router/tests/link.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ import {
getIntersectionObserverMock,
getSearchParamsFromURI,
sleep,
trailingSlashCases,
} from './utils'
import type { RouterHistory } from '../src'

Expand Down Expand Up @@ -5549,6 +5550,99 @@ describe.each([{ basepath: '' }, { basepath: '/basepath' }])(
},
)

describe("splat routes with empty splat", () => {
test.each(trailingSlashCases)(
'should handle empty _splat parameter with trailingSlash: $trailingSlash',
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
51 changes: 51 additions & 0 deletions packages/react-router/tests/navigate.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
createRoute,
createRouter,
} from '../src'
import { trailingSlashCases } from './utils'
import type { RouterHistory } from '../src'

afterEach(() => {
Expand Down Expand Up @@ -1238,3 +1239,53 @@ 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(trailingSlashCases)(
'should handle empty _splat parameter with trailingSlash: $trailingSlash',
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()
}
},
)
})
125 changes: 125 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,7 @@ import {
} from '@testing-library/react'

import { z } from 'zod'

import {
Navigate,
Outlet,
Expand All @@ -23,6 +24,7 @@ import {
useNavigate,
useParams,
} from '../src'
import { trailingSlashCases } from './utils'
import type { RouterHistory } from '../src'

let history: RouterHistory
Expand Down Expand Up @@ -2555,3 +2557,126 @@ describe.each([{ basepath: '' }, { basepath: '/basepath' }])(
})
},
)

describe('splat routes with empty splat', () => {
test.each(trailingSlashCases)(
'should handle empty _splat parameter with trailingSlash: $trailingSlash',
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/react-router/tests/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,3 +54,5 @@ export function getSearchParamsFromURI(uri: string) {
const [, paramString] = uri.split('?')
return new URLSearchParams(paramString)
}

export const trailingSlashCases = (['preserve','always','never'] as const).map((t) => ({ trailingSlash: t }))
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
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