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
50 changes: 50 additions & 0 deletions packages/react-router/tests/link-destination.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -305,4 +305,54 @@ describe('Link destination updates', () => {
expect(link).toHaveAttribute('data-status', 'active')
expect(link).toHaveTextContent('Current item')
})

test('masked destinations follow their inputs', async () => {
// A stable mask object that inherits params, and a mask prop that changes.
const inheritedMask = { to: '/visible/$id', params: true } as const
const rootRoute = createRootRoute({
component: function Root() {
const [id, setId] = React.useState('one')
return (
<>
<button onClick={() => setId('two')}>Change mask</button>
<Link
to="/target/$id"
params={{ id: 'fixed' }}
mask={{ to: '/visible/$id', params: { id } }}
data-testid="prop-mask"
/>
<Link
to="/target/$id"
params={{ id: 'fixed' }}
mask={inheritedMask as any}
data-testid="inherited-mask"
/>
<Outlet />
</>
)
},
})
const routes = ['/source/$id', '/target/$id', '/visible/$id'].map((path) =>
createRoute({ getParentRoute: () => rootRoute, path }),
)
const router = createRouter({
routeTree: rootRoute.addChildren(routes),
history: createMemoryHistory({ initialEntries: ['/source/one'] }),
})
render(<RouterProvider router={router} />)

const propMask = await screen.findByTestId('prop-mask')
const inherited = screen.getByTestId('inherited-mask')
expect(propMask).toHaveAttribute('href', '/visible/one')
expect(inherited).toHaveAttribute('href', '/visible/one')

await act(() =>
router.navigate({ to: '/source/$id', params: { id: 'two' } } as any),
)
expect(inherited).toHaveAttribute('href', '/visible/two')
expect(propMask).toHaveAttribute('href', '/visible/one')

fireEvent.click(screen.getByRole('button', { name: 'Change mask' }))
expect(propMask).toHaveAttribute('href', '/visible/two')
})
})
37 changes: 24 additions & 13 deletions packages/react-router/tests/link.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1151,10 +1151,13 @@ describe('Link', () => {
expect(indexLink).not.toHaveAttribute('data-status', 'active')
expect(indexLink).toHaveAttribute('href', '/')

expect(postsLink).toHaveAttribute('data-status', 'active')
expect(postsLink).toHaveAttribute('aria-current', 'page')
expect(postsLink).toHaveClass('active')
expect(postsLink).toHaveAttribute('href', '/posts')
// The clicked link left with the index page; check the posts page's own.
const activePostsLink = await screen.findByRole('link', { name: 'Posts' })
expect(activePostsLink).not.toBe(postsLink)
expect(activePostsLink).toHaveAttribute('data-status', 'active')
expect(activePostsLink).toHaveAttribute('aria-current', 'page')
expect(activePostsLink).toHaveClass('active')
expect(activePostsLink).toHaveAttribute('href', '/posts')
})

test('when navigating to /posts with a base url', async () => {
Expand Down Expand Up @@ -1211,10 +1214,13 @@ describe('Link', () => {
expect(indexLink).not.toHaveAttribute('data-status', 'active')
expect(indexLink).toHaveAttribute('href', '/app/')

expect(postsLink).toHaveAttribute('data-status', 'active')
expect(postsLink).toHaveAttribute('aria-current', 'page')
expect(postsLink).toHaveClass('active')
expect(postsLink).toHaveAttribute('href', '/app/posts')
// The clicked link left with the index page; check the posts page's own.
const activePostsLink = await screen.findByRole('link', { name: 'Posts' })
expect(activePostsLink).not.toBe(postsLink)
expect(activePostsLink).toHaveAttribute('data-status', 'active')
expect(activePostsLink).toHaveAttribute('aria-current', 'page')
expect(activePostsLink).toHaveClass('active')
expect(activePostsLink).toHaveAttribute('href', '/app/posts')
})

test('when navigating to /posts with search', async () => {
Expand Down Expand Up @@ -6803,14 +6809,12 @@ describe('splat routes with empty splat', () => {
async (trailingSlash) => {
const tail = trailingSlash === 'always' ? '/' : ''

const rootRoute = createRootRoute()
const indexRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/',
// The links stay mounted across the navigation so they can turn active.
const rootRoute = createRootRoute({
component: () => {
return (
<>
<h1>Index Route</h1>
<Outlet />
<Link
data-testid="splat-link-with-empty-splat"
to="/splat/$"
Expand Down Expand Up @@ -6839,6 +6843,13 @@ describe('splat routes with empty splat', () => {
)
},
})
const indexRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/',
component: () => {
return <h1>Index Route</h1>
},
})

const splatRoute = createRoute({
getParentRoute: () => rootRoute,
Expand Down
257 changes: 257 additions & 0 deletions packages/solid-router/tests/link-destination.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,257 @@
import { cleanup, fireEvent, render, screen } from '@solidjs/testing-library'
import { afterEach, expect, test, vi } from 'vitest'
import { createSignal } from 'solid-js'
import { createStore } from 'solid-js/store'
import {
Link,
Outlet,
RouterProvider,
createMemoryHistory,
createRootRoute,
createRoute,
createRouter,
defaultStringifySearch,
useLinkProps,
} from '../src'
import type { JSX } from 'solid-js'

afterEach(cleanup)

/**
* Renders `links` in the root route next to `/source/$id`, `/target/$id` and
* `/visible/$id`. `builds()` counts the destination builds that stringify a
* search carrying a `build` key, so a reused location does not count.
*/
function setup(links: () => JSX.Element, initial = '/source/one') {
const counted = vi.fn(defaultStringifySearch)
const root = createRootRoute({
component: () => (
<>
{links()}
<Outlet />
</>
),
})
const routes = ['/source/$id', '/target/$id', '/visible/$id'].map((path) =>
createRoute({ getParentRoute: () => root, path }),
)
const router = createRouter({
routeTree: root.addChildren(routes),
history: createMemoryHistory({ initialEntries: [initial] }),
stringifySearch: (search) =>
'build' in search ? counted(search) : defaultStringifySearch(search),
})
render(() => <RouterProvider router={router} />)
const go = (id: string, extra?: object) =>
router.navigate({ to: '/source/$id', params: { id }, ...extra } as any)
return { router, go, builds: () => counted.mock.calls.length }
}

test('store-backed params and search follow in-place mutations at the next navigation', async () => {
const [params, setParams] = createStore({ id: 'one' })
const [search, setSearch] = createStore({ build: 1 })
const { go } = setup(() => (
<Link to="/target/$id" params={params} search={search} data-testid="link" />
))
const link = await screen.findByTestId('link')
expect(link).toHaveAttribute('href', '/target/one?build=1')

setParams('id', 'two')
setSearch('build', 2)
// As before: a store read during a build is not tracked.
expect(link).toHaveAttribute('href', '/target/one?build=1')
await go('two')
expect(link).toHaveAttribute('href', '/target/two?build=2')

setParams('id', 'three')
await go('three')
expect(link).toHaveAttribute('href', '/target/three?build=2')

// Clicks and preloads read the store as it is now.
setParams('id', 'four')
fireEvent.click(link)
await vi.waitFor(() =>
expect(screen.getByTestId('link')).toHaveAttribute('data-status', 'active'),
)
expect(link).toHaveAttribute('href', '/target/four?build=2')
})

test('state store values are not reused either', async () => {
const [state, setState] = createStore({ label: 'one' })
const seen: Array<unknown> = []
const { router } = setup(() => (
<Link
to="/target/$id"
params={{ id: 'fixed' }}
state={state as any}
data-testid="link"
/>
))
const link = await screen.findByTestId('link')
router.subscribe('onResolved', ({ toLocation }) => {
seen.push((toLocation.state as any).label)
})
setState('label', 'two')
fireEvent.click(link)
await vi.waitFor(() => expect(seen).toEqual(['two']))
})

test('inherited params and search rebuild on every navigation', async () => {
const { go } = setup(() => (
<Link
to="/target/$id"
params={true as any}
search={true as any}
data-testid="link"
/>
))
const link = await screen.findByTestId('link')
expect(link).toHaveAttribute('href', '/target/one')
await go('two', { search: { page: 2 } })
expect(link).toHaveAttribute('href', '/target/two?page=2')
await go('three', { search: { page: 3 } })
expect(link).toHaveAttribute('href', '/target/three?page=3')
})

test('masked destinations follow their inputs', async () => {
const [params, setParams] = createStore({ id: 'one' })
const [hash, setHash] = createSignal('a')
const { go } = setup(() => (
<>
<Link
to="/target/$id"
params={{ id: 'fixed' }}
mask={{ to: '/visible/$id', params }}
data-testid="store-mask"
/>
<Link
to="/target/$id"
params={{ id: 'fixed' }}
mask={{ to: '/visible/$id', params: true, hash: hash() } as any}
data-testid="inherited-mask"
/>
</>
))
const storeMask = await screen.findByTestId('store-mask')
const inheritedMask = screen.getByTestId('inherited-mask')
expect(storeMask).toHaveAttribute('href', '/visible/one')
expect(inheritedMask).toHaveAttribute('href', '/visible/one#a')
setParams('id', 'two')
await go('two')
expect(storeMask).toHaveAttribute('href', '/visible/two')
expect(inheritedMask).toHaveAttribute('href', '/visible/two#a')
setHash('b')
expect(inheritedMask).toHaveAttribute('href', '/visible/two#b')
})

test('never mutates frozen caller options', async () => {
const params = Object.freeze({ id: 'fixed' })
const options = Object.freeze({
to: '/target/$id',
params,
search: Object.freeze({ build: 1 }),
'data-testid': 'frozen',
})
const Links = () => {
const frozen = useLinkProps(options as any)
return <a {...frozen} />
}
const result = setup(() => <Links />)
const frozen = await screen.findByTestId('frozen')
expect(frozen).toHaveAttribute('href', '/target/fixed?build=1')
await result.go('two')
expect(frozen).toHaveAttribute('href', '/target/fixed?build=1')
expect(Object.keys(options)).toEqual([
'to',
'params',
'search',
'data-testid',
])
expect(Object.keys(params)).toEqual(['id'])

fireEvent.click(frozen)
await vi.waitFor(() =>
expect(result.router.state.location.pathname).toBe('/target/fixed'),
)
})

test.each([
['inherited state', true],
['a state updater', (prev: any) => ({ label: `${prev.label}!` })],
])(
'a click after a same-href, state-only navigation uses the new state (%s)',
async (_, state) => {
const { router, go } = setup(() => (
<Link
to="/target/$id"
params={{ id: 'fixed' }}
state={state as any}
data-testid="link"
/>
))
const link = await screen.findByTestId('link')
await go('one', { state: { label: 'next' } })
expect(router.state.location.state).toMatchObject({ label: 'next' })
fireEvent.click(link)
await vi.waitFor(() =>
expect(router.state.location.pathname).toBe('/target/fixed'),
)
expect(router.state.location.state).toMatchObject({
label: state === true ? 'next' : 'next!',
})
},
)

test('function children render once per active state change', async () => {
const calls: Array<boolean> = []
const { go } = setup(() => (
<Link to="/target/$id" params={{ id: 'fixed' }} data-testid="link">
{({ isActive }) => {
calls.push(isActive)
return isActive ? 'active' : 'inactive'
}}
</Link>
))
const link = await screen.findByTestId('link')
expect(link).toHaveTextContent('inactive')
await go('two')
await go('three')
expect(calls).toEqual([false])
fireEvent.click(link)
await vi.waitFor(() => expect(link).toHaveTextContent(/^active$/))
expect(calls).toEqual([false, true])
})

test('refreshes history formatting even when the destination is reused', async () => {
let suffix = 'old'
const root = createRootRoute({
component: () => (
<>
<Link to="/target" data-testid="formatted" />
<Outlet />
</>
),
})
const source = createRoute({
getParentRoute: () => root,
path: '/source/$id',
})
const target = createRoute({ getParentRoute: () => root, path: '/target' })
const history = createMemoryHistory({ initialEntries: ['/source/one'] })
const router = createRouter({
routeTree: root.addChildren([source, target]),
history: {
...history,
get location() {
return history.location
},
createHref: (href) => `${href}#${suffix}`,
},
})
render(() => <RouterProvider router={router} />)
const link = await screen.findByTestId('formatted')
expect(link).toHaveAttribute('href', '/target#old')
suffix = 'new'
await router.navigate({ to: '/source/$id', params: { id: 'two' } })
expect(link).toHaveAttribute('href', '/target#new')
})
Loading
Loading