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
27 changes: 18 additions & 9 deletions packages/react-router/src/link.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -376,10 +376,17 @@ export function useLinkProps<
// eslint-disable-next-line react-hooks/rules-of-hooks
const isHydrated = useHydrated()

// subscribe to search params to re-build location if it changes
// subscribe to path/search/hash/params to re-build location when they change
// eslint-disable-next-line react-hooks/rules-of-hooks
const currentSearch = useRouterState({
select: (s) => s.location.search,
const currentLocationState = useRouterState({
select: (s) => {
const leaf = s.matches[s.matches.length - 1]
return {
search: leaf?.search,
hash: s.location.hash,
path: leaf?.pathname, // path + params
}
},
structuralSharing: true as any,
})

Expand All @@ -393,7 +400,7 @@ export function useLinkProps<
// eslint-disable-next-line react-hooks/exhaustive-deps
[
router,
currentSearch,
currentLocationState,
from,
options._fromLocation,
options.hash,
Expand Down Expand Up @@ -556,11 +563,13 @@ export function useLinkProps<

// eslint-disable-next-line react-hooks/rules-of-hooks
const doPreload = React.useCallback(() => {
router.preloadRoute({ ..._options } as any).catch((err) => {
console.warn(err)
console.warn(preloadWarning)
})
}, [router, _options])
router
.preloadRoute({ ..._options, _builtLocation: next } as any)
.catch((err) => {
console.warn(err)
console.warn(preloadWarning)
})
}, [router, _options, next])

// eslint-disable-next-line react-hooks/rules-of-hooks
const preloadViewportIoCallback = React.useCallback(
Expand Down
114 changes: 114 additions & 0 deletions packages/react-router/tests/link.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1521,6 +1521,120 @@ describe('Link', () => {
expect(paramText).toBeInTheDocument()
})

test('keeps a relative link active when changing inherited params (issue #5655)', async () => {
const rootRoute = createRootRoute()

const PostRouteComponent = () => {
const { postId } = useParams({ strict: false })

return (
<>
<Link
data-testid="step1-link"
from="/post/$postId"
to="step1"
activeProps={{ className: 'active' }}
>
Step 1
</Link>
<Link
data-testid="step2-link"
from="/post/$postId"
to="step2"
params={{ postId }}
activeProps={{ className: 'active' }}
>
Step 2
</Link>
<Outlet />
</>
)
}

const postRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/post/$postId',
component: PostRouteComponent,
})

const Step1RouteComponent = () => {
const { postId } = useParams({ strict: false })
const otherPostId = postId === '1' ? '2' : '1'

return (
<>
<span>{`Post ${postId} step1`}</span>
<Link
data-testid="switch-post-link"
from="/post/$postId/step1"
to="."
params={{ postId: otherPostId }}
>{`Go to post ${otherPostId}`}</Link>
</>
)
}
const step1Route = createRoute({
getParentRoute: () => postRoute,
path: 'step1',
component: Step1RouteComponent,
})

const Step2RouteComponent = () => {
const { postId } = useParams({ strict: false })
const otherPostId = postId === '1' ? '2' : '1'

return (
<>
<span>{`Post ${postId} step2`}</span>
<Link
data-testid="switch-post-link"
from="/post/$postId/step2"
to="."
params={{ postId: otherPostId }}
>{`Go to post ${otherPostId}`}</Link>
</>
)
}
const step2Route = createRoute({
getParentRoute: () => postRoute,
path: 'step2',
component: Step2RouteComponent,
})

const router = createRouter({
routeTree: rootRoute.addChildren([
postRoute.addChildren([step1Route, step2Route]),
]),
history: createMemoryHistory({
initialEntries: ['/post/1/step1'],
}),
})

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

expect(await screen.findByText('Post 1 step1')).toBeInTheDocument()
expect(screen.getByTestId('step1-link')).toHaveClass('active')

await act(() => fireEvent.click(screen.getByTestId('switch-post-link')))

expect(await screen.findByText('Post 2 step1')).toBeInTheDocument()
expect(router.state.location.pathname).toBe('/post/2/step1')
// This is the bug from #5655: step1 should stay active but is not.
expect(screen.getByTestId('step1-link')).toHaveClass('active')

await act(() => fireEvent.click(screen.getByTestId('step2-link')))

expect(await screen.findByText('Post 2 step2')).toBeInTheDocument()
expect(router.state.location.pathname).toBe('/post/2/step2')
expect(screen.getByTestId('step2-link')).toHaveClass('active')

await act(() => fireEvent.click(screen.getByTestId('switch-post-link')))

expect(await screen.findByText('Post 1 step2')).toBeInTheDocument()
expect(router.state.location.pathname).toBe('/post/1/step2')
expect(screen.getByTestId('step2-link')).toHaveClass('active')
})

test('when navigating from /posts to ./$postId', async () => {
const rootRoute = createRootRoute()
const indexRoute = createRoute({
Expand Down
10 changes: 8 additions & 2 deletions packages/router-core/src/router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -661,7 +661,13 @@ export type PreloadRouteFn<
TTo,
TMaskFrom,
TMaskTo
>,
> & {
/**
* @internal
* A **trusted** built location that can be used to redirect to.
*/
_builtLocation?: ParsedLocation
},
) => Promise<Array<AnyRouteMatch> | undefined>

export type MatchRouteFn<
Expand Down Expand Up @@ -2757,7 +2763,7 @@ export class RouterCore<
TDefaultStructuralSharingOption,
TRouterHistory
> = async (opts) => {
const next = this.buildLocation(opts as any)
const next = opts._builtLocation ?? this.buildLocation(opts as any)

let matches = this.matchRoutes(next, {
throwOnError: true,
Expand Down
112 changes: 112 additions & 0 deletions packages/solid-router/tests/link.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1546,6 +1546,118 @@ describe('Link', () => {
expect(paramText).toBeInTheDocument()
})

test('keeps a relative link active when changing inherited params (issue #5655)', async () => {
const rootRoute = createRootRoute()

const postRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/post/$postId',
component: () => {
const params = useParams({ strict: false })
const postId = () => params().postId

return (
<>
<Link
data-testid="step1-link"
from="/post/$postId"
to="step1"
activeProps={{ class: 'active' }}
>
Step 1
</Link>
<Link
data-testid="step2-link"
from="/post/$postId"
to="step2"
params={{ postId: postId() }}
activeProps={{ class: 'active' }}
>
Step 2
</Link>
<Outlet />
</>
)
},
})

const step1Route = createRoute({
getParentRoute: () => postRoute,
path: 'step1',
component: () => {
const params = useParams({ strict: false })
const postId = () => params().postId
const otherPostId = () => (postId() === '1' ? '2' : '1')

return (
<>
<span>{`Post ${postId()} step1`}</span>
<Link
data-testid="switch-post-link"
from="/post/$postId/step1"
to="."
params={{ postId: otherPostId() }}
>{`Go to post ${otherPostId()}`}</Link>
</>
)
},
})

const step2Route = createRoute({
getParentRoute: () => postRoute,
path: 'step2',
component: () => {
const params = useParams({ strict: false })
const postId = () => params().postId
const otherPostId = () => (postId() === '1' ? '2' : '1')

return (
<>
<span>{`Post ${postId()} step2`}</span>
<Link
data-testid="switch-post-link"
from="/post/$postId/step2"
to="."
params={{ postId: otherPostId() }}
>{`Go to post ${otherPostId()}`}</Link>
</>
)
},
})

const router = createRouter({
routeTree: rootRoute.addChildren([
postRoute.addChildren([step1Route, step2Route]),
]),
history: createMemoryHistory({
initialEntries: ['/post/1/step1'],
}),
})

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

expect(await screen.findByText('Post 1 step1')).toBeInTheDocument()
expect(screen.getByTestId('step1-link')).toHaveClass('active')

fireEvent.click(screen.getByTestId('switch-post-link'))

expect(await screen.findByText('Post 2 step1')).toBeInTheDocument()
expect(router.state.location.pathname).toBe('/post/2/step1')
expect(screen.getByTestId('step1-link')).toHaveClass('active')

fireEvent.click(screen.getByTestId('step2-link'))

expect(await screen.findByText('Post 2 step2')).toBeInTheDocument()
expect(router.state.location.pathname).toBe('/post/2/step2')
expect(screen.getByTestId('step2-link')).toHaveClass('active')

fireEvent.click(screen.getByTestId('switch-post-link'))

expect(await screen.findByText('Post 1 step2')).toBeInTheDocument()
expect(router.state.location.pathname).toBe('/post/1/step2')
expect(screen.getByTestId('step2-link')).toHaveClass('active')
})

test('when navigating from /posts to ./$postId', async () => {
const rootRoute = createRootRoute()
const indexRoute = createRoute({
Expand Down
Loading
Loading