From 4bcc134e26745c07ea8ef5819bfc0a0006634b3c Mon Sep 17 00:00:00 2001 From: Sheraff Date: Sun, 4 Oct 2026 22:32:34 +0200 Subject: [PATCH] test(router): Link tests that hold on main, ahead of the departing-Link work Tests written for #8602 that already pass on main: rewrites of Link tests that asserted on a detached anchor after its route unmounted, and Link destination tests for Solid, Vue and React (in-place store/reactive inputs, inherited params, masks, frozen options, state-only navigations). Co-Authored-By: Claude Opus 5.5 --- .../tests/link-destination.test.tsx | 50 ++++ packages/react-router/tests/link.test.tsx | 37 ++- .../tests/link-destination.test.tsx | 257 ++++++++++++++++++ packages/solid-router/tests/link.test.tsx | 37 ++- .../tests/link-destination.test.tsx | 168 ++++++++++++ packages/vue-router/tests/link.test.tsx | 37 ++- 6 files changed, 547 insertions(+), 39 deletions(-) create mode 100644 packages/solid-router/tests/link-destination.test.tsx create mode 100644 packages/vue-router/tests/link-destination.test.tsx diff --git a/packages/react-router/tests/link-destination.test.tsx b/packages/react-router/tests/link-destination.test.tsx index f33fe658035..8d27615ff96 100644 --- a/packages/react-router/tests/link-destination.test.tsx +++ b/packages/react-router/tests/link-destination.test.tsx @@ -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 ( + <> + + + + + + ) + }, + }) + 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() + + 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') + }) }) diff --git a/packages/react-router/tests/link.test.tsx b/packages/react-router/tests/link.test.tsx index 46e6cb86856..56c4ece9db3 100644 --- a/packages/react-router/tests/link.test.tsx +++ b/packages/react-router/tests/link.test.tsx @@ -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 () => { @@ -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 () => { @@ -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 ( <> -

Index Route

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

Index Route

+ }, + }) const splatRoute = createRoute({ getParentRoute: () => rootRoute, diff --git a/packages/solid-router/tests/link-destination.test.tsx b/packages/solid-router/tests/link-destination.test.tsx new file mode 100644 index 00000000000..93c20d2386d --- /dev/null +++ b/packages/solid-router/tests/link-destination.test.tsx @@ -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()} + + + ), + }) + 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(() => ) + 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(() => ( + + )) + 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 = [] + const { router } = setup(() => ( + + )) + 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(() => ( + + )) + 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(() => ( + <> + + + + )) + 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 + } + const result = setup(() => ) + 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(() => ( + + )) + 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 = [] + const { go } = setup(() => ( + + {({ isActive }) => { + calls.push(isActive) + return isActive ? 'active' : 'inactive' + }} + + )) + 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: () => ( + <> + + + + ), + }) + 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(() => ) + 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') +}) diff --git a/packages/solid-router/tests/link.test.tsx b/packages/solid-router/tests/link.test.tsx index 78c4b023474..caae3f55e18 100644 --- a/packages/solid-router/tests/link.test.tsx +++ b/packages/solid-router/tests/link.test.tsx @@ -1406,10 +1406,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 () => { @@ -1465,10 +1468,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 () => { @@ -6852,14 +6858,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 ( <> -

Index Route

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

Index Route

+ }, + }) const splatRoute = createRoute({ getParentRoute: () => rootRoute, diff --git a/packages/vue-router/tests/link-destination.test.tsx b/packages/vue-router/tests/link-destination.test.tsx new file mode 100644 index 00000000000..e7fd8e69621 --- /dev/null +++ b/packages/vue-router/tests/link-destination.test.tsx @@ -0,0 +1,168 @@ +import * as Vue from 'vue' +import { cleanup, fireEvent, render, screen } from '@testing-library/vue' +import { afterEach, expect, test, vi } from 'vitest' +import { + Link, + Outlet, + RouterProvider, + createMemoryHistory, + createRootRoute, + createRoute, + createRouter, + defaultStringifySearch, +} from '../src' + +afterEach(() => { + cleanup() + vi.restoreAllMocks() +}) + +/** + * 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: () => any, initial = '/source/one') { + const counted = vi.fn(defaultStringifySearch) + const root = createRootRoute({ + component: () => ( + <> + {links()} + + + ), + }) + 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() + 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('reactive params and search follow in-place mutations at the next navigation', async () => { + const params = Vue.reactive({ id: 'one' }) + const search = Vue.reactive({ build: 1 }) + const { go } = setup(() => ( + + )) + const link = await screen.findByTestId('link') + expect(link).toHaveAttribute('href', '/target/one?build=1') + params.id = 'two' + search.build = 2 + await go('two') + await Vue.nextTick() + expect(link).toHaveAttribute('href', '/target/two?build=2') + params.id = 'three' + await go('three') + await Vue.nextTick() + expect(link).toHaveAttribute('href', '/target/three?build=2') +}) + +test('inherited params and search rebuild on every navigation', async () => { + const { go } = setup(() => ( + + )) + const link = await screen.findByTestId('link') + expect(link).toHaveAttribute('href', '/target/one') + await go('two', { search: { page: 2 } }) + await Vue.nextTick() + expect(link).toHaveAttribute('href', '/target/two?page=2') +}) + +test('masked destinations follow their inputs', async () => { + const params = Vue.reactive({ id: 'one' }) + const fixed = { id: 'fixed' } + const mask = { to: '/visible/$id', params } + const { go } = setup(() => ( + + )) + const link = await screen.findByTestId('link') + expect(link).toHaveAttribute('href', '/visible/one') + params.id = 'two' + await go('two') + await Vue.nextTick() + expect(link).toHaveAttribute('href', '/visible/two') +}) + +test('switches a mounted Link between plain and reactive inputs', async () => { + const live = Vue.ref(false) + const params = Vue.reactive({ id: 'one' }) + const plain = { id: 'fixed' } + const { go } = setup(() => ( + + )) + const link = await screen.findByTestId('link') + expect(link).toHaveAttribute('href', '/target/fixed') + live.value = true + await Vue.nextTick() + expect(link).toHaveAttribute('href', '/target/one') + params.id = 'two' + await go('two') + await Vue.nextTick() + expect(link).toHaveAttribute('href', '/target/two') + live.value = false + await Vue.nextTick() + expect(link).toHaveAttribute('href', '/target/fixed') +}) + +test('never mutates frozen caller options', async () => { + const params = Object.freeze({ id: 'fixed' }) + const search = Object.freeze({ build: 1 }) + const { go } = setup(() => ( + + )) + const link = await screen.findByTestId('link') + await go('two') + await Vue.nextTick() + expect(link).toHaveAttribute('href', '/target/fixed?build=1') + expect(Object.keys(params)).toEqual(['id']) +}) + +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 fixed = { id: 'fixed' } + const { router, go } = setup(() => ( + + )) + const link = await screen.findByTestId('link') + await go('one', { state: { label: 'next' } }) + await 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!', + }) + }, +) diff --git a/packages/vue-router/tests/link.test.tsx b/packages/vue-router/tests/link.test.tsx index 48fe82e2bb6..bae8cf04f9b 100644 --- a/packages/vue-router/tests/link.test.tsx +++ b/packages/vue-router/tests/link.test.tsx @@ -1547,10 +1547,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 () => { @@ -1606,10 +1609,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 () => { @@ -7311,14 +7317,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 ( <> -

Index Route

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

Index Route

+ }, + }) const splatRoute = createRoute({ getParentRoute: () => rootRoute,