diff --git a/.changeset/warm-routes-rest.md b/.changeset/warm-routes-rest.md
new file mode 100644
index 00000000000..37e759c41e5
--- /dev/null
+++ b/.changeset/warm-routes-rest.md
@@ -0,0 +1,6 @@
+---
+'@tanstack/solid-router': patch
+'@tanstack/vue-router': patch
+---
+
+Keep active route components mounted by default when route params change.
diff --git a/packages/solid-router/src/Match.tsx b/packages/solid-router/src/Match.tsx
index 563960dd6b3..a6db669e11e 100644
--- a/packages/solid-router/src/Match.tsx
+++ b/packages/solid-router/src/Match.tsx
@@ -207,13 +207,16 @@ export const MatchInner = (): any => {
const current = currentMatch()
const remount =
route.options.remountDeps ?? router.options.defaultRemountDeps
- const deps = remount?.({
+ if (!remount) {
+ return routeId()
+ }
+ const deps = remount({
routeId: routeId()!,
loaderDeps: current.loaderDeps,
params: current._strictParams,
search: current._strictSearch,
})
- return deps ? JSON.stringify(deps) : current.id
+ return JSON.stringify(deps) ?? routeId()
}
const out = () => {
diff --git a/packages/solid-router/tests/remountDeps.test.tsx b/packages/solid-router/tests/remountDeps.test.tsx
new file mode 100644
index 00000000000..991ee786f3e
--- /dev/null
+++ b/packages/solid-router/tests/remountDeps.test.tsx
@@ -0,0 +1,99 @@
+import * as Solid from 'solid-js'
+import { cleanup, render, screen } from '@solidjs/testing-library'
+import { afterEach, expect, test, vi } from 'vitest'
+import {
+ Outlet,
+ RouterProvider,
+ createMemoryHistory,
+ createRootRoute,
+ createRoute,
+ createRouter,
+} from '../src'
+
+afterEach(() => {
+ cleanup()
+})
+
+function setup(remountOnParams: boolean | 'falsy' = false) {
+ const mounted = vi.fn()
+ const unmounted = vi.fn()
+ const rootRoute = createRootRoute({ component: () => })
+
+ function ItemComponent() {
+ const params = itemRoute.useParams()
+
+ Solid.onMount(mounted)
+ Solid.onCleanup(unmounted)
+
+ return
Item {params().itemId}
+ }
+
+ const itemRoute = createRoute({
+ getParentRoute: () => rootRoute,
+ path: '/items/$itemId',
+ component: ItemComponent,
+ remountDeps:
+ remountOnParams === 'falsy'
+ ? ({ params }) => (params.itemId === 'one' ? false : 0)
+ : remountOnParams
+ ? ({ params }) => params
+ : undefined,
+ })
+ const router = createRouter({
+ routeTree: rootRoute.addChildren([itemRoute]),
+ history: createMemoryHistory({ initialEntries: ['/items/one'] }),
+ })
+
+ render(() => )
+
+ return { mounted, router, unmounted }
+}
+
+async function navigateToSecondItem(
+ router: ReturnType['router'],
+) {
+ await router.navigate({
+ to: '/items/$itemId',
+ params: { itemId: 'two' },
+ })
+}
+
+test('keeps an active route component mounted when params change by default', async () => {
+ const { mounted, router, unmounted } = setup()
+
+ expect(await screen.findByText('Item one')).toBeInTheDocument()
+ expect(mounted).toHaveBeenCalledTimes(1)
+ expect(unmounted).not.toHaveBeenCalled()
+
+ await navigateToSecondItem(router)
+
+ expect(await screen.findByText('Item two')).toBeInTheDocument()
+ expect(mounted).toHaveBeenCalledTimes(1)
+ expect(unmounted).not.toHaveBeenCalled()
+})
+
+test('remounts an active route component when params are remount deps', async () => {
+ const { mounted, router, unmounted } = setup(true)
+
+ expect(await screen.findByText('Item one')).toBeInTheDocument()
+ expect(mounted).toHaveBeenCalledTimes(1)
+
+ await navigateToSecondItem(router)
+
+ expect(await screen.findByText('Item two')).toBeInTheDocument()
+ expect(mounted).toHaveBeenCalledTimes(2)
+ expect(unmounted).toHaveBeenCalledTimes(1)
+})
+
+test('remounts when remount deps change between falsy values', async () => {
+ const { mounted, router, unmounted } = setup('falsy')
+
+ expect(await screen.findByText('Item one')).toBeInTheDocument()
+ expect(mounted).toHaveBeenCalledTimes(1)
+
+ await navigateToSecondItem(router)
+
+ expect(await screen.findByText('Item two')).toBeInTheDocument()
+ expect(mounted).toHaveBeenCalledTimes(2)
+ expect(unmounted).toHaveBeenCalledTimes(1)
+})
diff --git a/packages/vue-router/src/Match.tsx b/packages/vue-router/src/Match.tsx
index 2f4cd34074b..0a643447f02 100644
--- a/packages/vue-router/src/Match.tsx
+++ b/packages/vue-router/src/Match.tsx
@@ -176,19 +176,18 @@ export const MatchInner = Vue.defineComponent({
(router.routesById[matchRouteId] as AnyRoute).options.remountDeps ??
router.options.defaultRemountDeps
- const remountDeps = remountFn
- ? remountFn({
- routeId: matchRouteId,
- loaderDeps: match.loaderDeps,
- params: match._strictParams,
- search: match._strictSearch,
- })
+ const remountKey = remountFn
+ ? JSON.stringify(
+ remountFn({
+ routeId: matchRouteId,
+ loaderDeps: match.loaderDeps,
+ params: match._strictParams,
+ search: match._strictSearch,
+ }),
+ )
: undefined
- return [
- match,
- remountDeps ? JSON.stringify(remountDeps) : undefined,
- ] as const
+ return [match, remountKey] as const
})
return (): VNode | null => {
@@ -300,17 +299,11 @@ export const Outlet = Vue.defineComponent({
const route = router.routesById[parentRouteId]!
- const childMatch = useStore(router.stores.matches, (matches) => {
+ const childRouteId = useStore(router.stores.matches, (matches) => {
const index = matches.findIndex(
(match) => match.routeId === parentRouteId,
)
- const child = matches[index + 1]
- return child
- ? ([
- child.routeId,
- child.routeId + JSON.stringify(child._strictParams),
- ] as const)
- : undefined
+ return matches[index + 1]?.routeId
})
return (): VNode | null => {
@@ -318,17 +311,14 @@ export const Outlet = Vue.defineComponent({
return renderRouteNotFound(router, route, parentMatch.value.error)
}
- const child = childMatch.value
+ const child = childRouteId.value
if (!child) {
return null
}
const nextMatch = Vue.h(Match, {
- routeId: child[0 /* routeId */],
- // Key based on routeId + params only (not loaderDeps)
- // This ensures component recreates when params change,
- // but NOT when only loaderDeps change
- key: child[1 /* key */],
+ routeId: child,
+ key: child,
})
// Note: We intentionally do NOT wrap in Suspense here.
diff --git a/packages/vue-router/src/link.tsx b/packages/vue-router/src/link.tsx
index 3c50fe1f755..ccff4e90060 100644
--- a/packages/vue-router/src/link.tsx
+++ b/packages/vue-router/src/link.tsx
@@ -58,6 +58,17 @@ type VueStyleLinkEventHandlers = {
onTouchstart?: EventHandler
}
+type LinkEventHandlers = {
+ onClick: EventHandler
+ onBlur: EventHandler
+ onFocus: EventHandler
+ onMouseenter: EventHandler
+ onMouseleave: EventHandler
+ onMouseover: EventHandler
+ onMouseout: EventHandler
+ onTouchstart: EventHandler
+}
+
interface StyledProps {
class?: LinkHTMLAttributes['class']
style?: LinkHTMLAttributes['style']
@@ -73,7 +84,9 @@ type PropsOfComponent =
? P
: Record
-type AnyLinkPropsOptions = UseLinkPropsOptions
+type AnyLinkPropsOptions = UseLinkPropsOptions & {
+ _asChild?: unknown
+}
type LinkEventOptions = AnyLinkPropsOptions & Partial
export function useLinkProps<
@@ -84,9 +97,15 @@ export function useLinkProps<
TMaskTo extends string = '',
>(
options: UseLinkPropsOptions,
+): LinkHTMLAttributes {
+ return useLinkPropsImpl(() => options as AnyLinkPropsOptions)
+}
+
+function useLinkPropsImpl(
+ getOptions: () => AnyLinkPropsOptions,
): LinkHTMLAttributes {
const router = useRouter()
- let hasRenderFetched = false
+ let renderFetchedHref: string | undefined
// Ensure router is defined before proceeding
if (!router) {
@@ -96,6 +115,7 @@ export function useLinkProps<
// Determine if the link is external or internal
const type = Vue.computed(() => {
+ const options = getOptions()
try {
new URL(`${options.to}`)
return 'external'
@@ -105,81 +125,19 @@ export function useLinkProps<
})
const ref = Vue.ref(null)
- const eventHandlers = getLinkEventHandlers(options as LinkEventOptions)
-
- if (type.value === 'external') {
- // Block dangerous protocols like javascript:, blob:, data:
- if (isDangerousProtocol(options.to as string, router.protocolAllowlist)) {
- if (process.env.NODE_ENV !== 'production') {
- console.warn(`Blocked Link with dangerous protocol: ${options.to}`)
- }
- // Return props without href to prevent navigation
- const safeProps: Record = {
- ...getPropsSafeToSpread(options as AnyLinkPropsOptions),
- ref,
- // No href attribute - blocks the dangerous protocol
- target: options.target,
- disabled: options.disabled,
- style: options.style,
- class: options.class,
- onClick: options.onClick,
- onBlur: options.onBlur,
- onFocus: options.onFocus,
- onMouseenter: eventHandlers.onMouseenter,
- onMouseleave: eventHandlers.onMouseleave,
- onMouseover: eventHandlers.onMouseover,
- onMouseout: eventHandlers.onMouseout,
- onTouchstart: eventHandlers.onTouchstart,
- }
-
- // Remove undefined values
- Object.keys(safeProps).forEach((key) => {
- if (safeProps[key] === undefined) {
- delete safeProps[key]
- }
- })
-
- return Vue.computed(
- () => safeProps as LinkHTMLAttributes,
- ) as unknown as LinkHTMLAttributes
- }
-
- // External links just have simple props
- const externalProps: Record = {
- ...getPropsSafeToSpread(options as AnyLinkPropsOptions),
- ref,
- href: options.to,
- target: options.target,
- disabled: options.disabled,
- style: options.style,
- class: options.class,
- onClick: options.onClick,
- onBlur: options.onBlur,
- onFocus: options.onFocus,
- onMouseenter: eventHandlers.onMouseenter,
- onMouseleave: eventHandlers.onMouseleave,
- onMouseover: eventHandlers.onMouseover,
- onMouseout: eventHandlers.onMouseout,
- onTouchstart: eventHandlers.onTouchstart,
- }
-
- // Remove undefined values
- Object.keys(externalProps).forEach((key) => {
- if (externalProps[key] === undefined) {
- delete externalProps[key]
- }
- })
-
- return Vue.computed(
- () => externalProps as LinkHTMLAttributes,
- ) as unknown as LinkHTMLAttributes
- }
// During SSR we render exactly once and do not need reactivity.
// Avoid store subscriptions, effects and observers on the server.
if (isServer ?? router.isServer) {
+ const options = getOptions()
+ if (type.value === 'external') {
+ return Vue.ref(
+ getExternalLinkProps(options, router, ref),
+ ) as unknown as LinkHTMLAttributes
+ }
+
const next = router.buildLocation(options as any)
- const href = getHref(options as AnyLinkPropsOptions, router, next)
+ const href = getHref(options, router, next)
const isActive = getIsActive(
router.stores.location.get(),
@@ -193,11 +151,11 @@ export function useLinkProps<
resolvedInactiveProps,
resolvedClassName,
resolvedStyle,
- } = resolveStyleProps(options as AnyLinkPropsOptions, isActive)
+ } = resolveStyleProps(options, isActive)
const result = combineResultProps({
href,
- options: options as AnyLinkPropsOptions,
+ options,
isActive,
resolvedActiveProps,
resolvedInactiveProps,
@@ -210,44 +168,78 @@ export function useLinkProps<
) as unknown as LinkHTMLAttributes
}
- const currentLocation = useStore(router.stores.location, (l) => l, {
- equal: (prev, next) => prev.href === next.href,
- })
+ const currentLocation: Vue.Ref<
+ ReturnType
+ > =
+ type.value === 'external'
+ ? Vue.shallowRef(router.stores.location.get())
+ : (useStore(router.stores.location, (l) => l, {
+ equal: (prev, next) => prev.href === next.href,
+ }) as Vue.Ref>)
+
+ // Links that start external skip useStore above. Subscribe if they later
+ // become internal so active state follows subsequent location changes.
+ if (type.value === 'external') {
+ Vue.watchEffect((onCleanup) => {
+ if (type.value === 'external') {
+ return
+ }
+
+ const store = router.stores.location
+ const subscription = store.subscribe((location) => {
+ if (currentLocation.value.href !== location.href) {
+ currentLocation.value = location
+ }
+ })
+ onCleanup(() => subscription.unsubscribe())
+ })
+ }
const next = Vue.computed(() => {
// Rebuild when inherited search/hash or the current route context changes.
+ const options = getOptions()
const opts = { _fromLocation: currentLocation.value, ...options }
return router.buildLocation(opts)
})
const preload = Vue.computed(() => {
- if (options.reloadDocument || options.disabled) {
+ const options = getOptions()
+ if (
+ type.value === 'external' ||
+ options.reloadDocument ||
+ options.disabled
+ ) {
return false
}
return options.preload ?? router.options.defaultPreload
})
const preloadDelay = Vue.computed(
- () => options.preloadDelay ?? router.options.defaultPreloadDelay ?? 0,
+ () => getOptions().preloadDelay ?? router.options.defaultPreloadDelay ?? 0,
)
- const isActive = Vue.computed(() =>
- getIsActive(
+ const isActive = Vue.computed(() => {
+ const options = getOptions()
+ return getIsActive(
currentLocation.value,
next.value,
options.activeOptions,
router,
- ),
- )
+ )
+ })
- const doPreload = () =>
- router
+ const doPreload = () => {
+ const options = getOptions()
+ return router
.preloadRoute({ ...options, _builtLocation: next.value } as any)
.catch((err: any) => {
console.warn(err)
console.warn(preloadWarning)
})
+ }
+
+ let pendingPreload: 'intent' | 'viewport' | undefined
const enqueuePreload = (
e?: MouseEvent | FocusEvent | IntersectionObserverEntry,
@@ -255,18 +247,17 @@ export function useLinkProps<
if (!e) {
clearTimeout(timeoutMap.get(ref))
timeoutMap.delete(ref)
+ pendingPreload = undefined
return
}
- if (
- !(
- (e as IntersectionObserverEntry).isIntersecting ??
- preload.value === 'intent'
- )
- ) {
- if ((e as IntersectionObserverEntry).isIntersecting === false) {
+ const isIntersecting = (e as IntersectionObserverEntry).isIntersecting
+ const preloadMode = isIntersecting === undefined ? 'intent' : 'viewport'
+ if (preload.value !== preloadMode || isIntersecting === false) {
+ if (isIntersecting === false && pendingPreload === 'viewport') {
clearTimeout(timeoutMap.get(ref))
timeoutMap.delete(ref)
+ pendingPreload = undefined
}
return
}
@@ -277,11 +268,19 @@ export function useLinkProps<
}
if (!timeoutMap.has(ref)) {
+ const scheduledHref = next.value.href
+ pendingPreload = preloadMode
timeoutMap.set(
ref,
setTimeout(() => {
timeoutMap.delete(ref)
- doPreload()
+ pendingPreload = undefined
+ if (
+ preload.value === preloadMode &&
+ next.value.href === scheduledHref
+ ) {
+ doPreload()
+ }
}, preloadDelay.value),
)
}
@@ -293,18 +292,25 @@ export function useLinkProps<
() => preload.value !== 'viewport',
)
- Vue.effect(() => {
- if (hasRenderFetched) {
+ Vue.watchEffect(() => {
+ if (preload.value !== 'render') {
return
}
- if (preload.value === 'render') {
+
+ const nextHref = next.value.href
+ if (nextHref && renderFetchedHref !== nextHref) {
+ renderFetchedHref = nextHref
doPreload()
- hasRenderFetched = true
}
})
// The click handler
const handleClick = (e: PointerEvent): void => {
+ if (type.value === 'external') {
+ return
+ }
+
+ const options = getOptions()
// Check actual element's target attribute as fallback
const elementTarget = (
e.currentTarget as HTMLAnchorElement | SVGAElement
@@ -340,72 +346,75 @@ export function useLinkProps<
}
const handleTouchStart = () => {
- if (preload.value !== 'intent') return
- doPreload()
+ if (preload.value === 'intent') {
+ doPreload()
+ }
}
const handleLeave = () => {
- if (preload.value === 'intent') {
+ if (pendingPreload === 'intent') {
clearTimeout(timeoutMap.get(ref))
timeoutMap.delete(ref)
+ pendingPreload = undefined
}
}
- // Helper to compose event handlers - with explicit return type and better type handling
function composeEventHandlers(
- handlers: Array | undefined>,
+ getUserHandler: () => EventHandler | undefined,
+ handler: EventHandler,
): (e: T) => void {
return (event: T) => {
- for (const handler of handlers) {
- if (handler) {
- handler(event)
- }
- }
+ getUserHandler()?.(event)
+ handler(event)
}
}
// Get the active and inactive props
- const resolvedStyleProps = Vue.computed(() =>
- resolveStyleProps(options as AnyLinkPropsOptions, isActive.value),
- )
+ const resolvedStyleProps = Vue.computed(() => {
+ const options = getOptions()
+ return resolveStyleProps(options, isActive.value)
+ })
- const href = Vue.computed(() =>
- getHref(options as AnyLinkPropsOptions, router, next.value),
- )
+ const href = Vue.computed(() => {
+ const options = getOptions()
+ return getHref(options, router, next.value)
+ })
// Create static event handlers that don't change between renders
- const staticEventHandlers = {
- onClick: composeEventHandlers([options.onClick, handleClick]),
- onBlur: composeEventHandlers([options.onBlur, handleLeave]),
- onFocus: composeEventHandlers([
- options.onFocus,
- enqueuePreload,
- ]),
- onMouseenter: composeEventHandlers([
- eventHandlers.onMouseenter,
+ const staticEventHandlers: LinkEventHandlers = {
+ onClick: composeEventHandlers(() => getOptions().onClick, handleClick),
+ onBlur: composeEventHandlers(() => getOptions().onBlur, handleLeave),
+ onFocus: composeEventHandlers(() => getOptions().onFocus, enqueuePreload),
+ onMouseenter: composeEventHandlers(
+ () => getLinkEventHandlers(getOptions() as LinkEventOptions).onMouseenter,
enqueuePreload,
- ]),
- onMouseover: composeEventHandlers([
- eventHandlers.onMouseover,
+ ),
+ onMouseover: composeEventHandlers(
+ () => getLinkEventHandlers(getOptions() as LinkEventOptions).onMouseover,
enqueuePreload,
- ]),
- onMouseleave: composeEventHandlers([
- eventHandlers.onMouseleave,
+ ),
+ onMouseleave: composeEventHandlers(
+ () => getLinkEventHandlers(getOptions() as LinkEventOptions).onMouseleave,
handleLeave,
- ]),
- onMouseout: composeEventHandlers([
- eventHandlers.onMouseout,
+ ),
+ onMouseout: composeEventHandlers(
+ () => getLinkEventHandlers(getOptions() as LinkEventOptions).onMouseout,
handleLeave,
- ]),
- onTouchstart: composeEventHandlers([
- eventHandlers.onTouchstart,
+ ),
+ onTouchstart: composeEventHandlers(
+ () => getLinkEventHandlers(getOptions() as LinkEventOptions).onTouchstart,
handleTouchStart,
- ]),
+ ),
}
// Compute all props synchronously to avoid hydration mismatches
// Using Vue.computed ensures props are calculated at render time, not after
const computedProps = Vue.computed(() => {
+ const options = getOptions()
+ if (type.value === 'external') {
+ return getExternalLinkProps(options, router, ref, staticEventHandlers)
+ }
+
const {
resolvedActiveProps,
resolvedInactiveProps,
@@ -414,7 +423,7 @@ export function useLinkProps<
} = resolvedStyleProps.value
return combineResultProps({
href: href.value,
- options: options as AnyLinkPropsOptions,
+ options,
ref,
staticEventHandlers,
isActive: isActive.value,
@@ -496,23 +505,14 @@ function combineResultProps({
resolvedClassName?: string
resolvedStyle?: Record
ref?: Vue.VNodeRef | undefined
- staticEventHandlers?: {
- onClick: any
- onBlur: any
- onFocus: any
- onMouseenter: any
- onMouseover: any
- onMouseleave: any
- onMouseout: any
- onTouchstart: any
- }
+ staticEventHandlers?: LinkEventHandlers
}) {
const result: Record = {
...getPropsSafeToSpread(options),
ref,
...staticEventHandlers,
href,
- disabled: !!options.disabled,
+ disabled: options._asChild ? !!options.disabled : undefined,
target: options.target,
}
@@ -548,6 +548,56 @@ function combineResultProps({
return result
}
+function getExternalLinkProps(
+ options: AnyLinkPropsOptions,
+ router: AnyRouter,
+ ref: Vue.Ref,
+ staticEventHandlers?: LinkEventHandlers,
+): LinkHTMLAttributes {
+ const dangerous = isDangerousProtocol(
+ options.to as string,
+ router.protocolAllowlist,
+ )
+ if (dangerous && process.env.NODE_ENV !== 'production') {
+ console.warn(`Blocked Link with dangerous protocol: ${options.to}`)
+ }
+
+ const eventHandlers = getLinkEventHandlers(options as LinkEventOptions)
+ const result: Record = {
+ ...getPropsSafeToSpread(options),
+ ref,
+ href: dangerous || options.disabled ? undefined : options.to,
+ target: options.target,
+ disabled: options._asChild ? !!options.disabled : undefined,
+ style: options.style,
+ class: options.class,
+ onClick: staticEventHandlers?.onClick ?? options.onClick,
+ onBlur: staticEventHandlers?.onBlur ?? options.onBlur,
+ onFocus: staticEventHandlers?.onFocus ?? options.onFocus,
+ onMouseenter:
+ staticEventHandlers?.onMouseenter ?? eventHandlers.onMouseenter,
+ onMouseleave:
+ staticEventHandlers?.onMouseleave ?? eventHandlers.onMouseleave,
+ onMouseover: staticEventHandlers?.onMouseover ?? eventHandlers.onMouseover,
+ onMouseout: staticEventHandlers?.onMouseout ?? eventHandlers.onMouseout,
+ onTouchstart:
+ staticEventHandlers?.onTouchstart ?? eventHandlers.onTouchstart,
+ }
+
+ if (options.disabled) {
+ result.role = 'link'
+ result['aria-disabled'] = true
+ }
+
+ for (const key of Object.keys(result)) {
+ if (result[key] === undefined) {
+ delete result[key]
+ }
+ }
+
+ return result as LinkHTMLAttributes
+}
+
function getLinkEventHandlers(
options: LinkEventOptions,
): VueStyleLinkEventHandlers {
@@ -842,9 +892,25 @@ const LinkImpl = Vue.defineComponent({
'target',
],
setup(props, { attrs, slots }) {
- // Call useLinkProps ONCE during setup with combined props and attrs
- const allProps = { ...props, ...attrs }
- const linkPropsSource = useLinkProps(allProps) as
+ const attrsSnapshot = Vue.shallowRef({ ...attrs })
+ Vue.onBeforeUpdate(() => {
+ const keys = Object.keys(attrs)
+ const previous = attrsSnapshot.value
+ if (
+ keys.length !== Object.keys(previous).length ||
+ keys.some((key) => !Object.is(attrs[key], previous[key]))
+ ) {
+ attrsSnapshot.value = { ...attrs }
+ }
+ })
+
+ // Keep a plain cached snapshot so location-only updates do not repeatedly
+ // cross Vue's props and attrs proxies for every link computation.
+ const allProps = Vue.computed(() => ({
+ ...props,
+ ...attrsSnapshot.value,
+ }))
+ const linkPropsSource = useLinkPropsImpl(() => allProps.value) as
| LinkHTMLAttributes
| Vue.ComputedRef
diff --git a/packages/vue-router/tests/link.test.tsx b/packages/vue-router/tests/link.test.tsx
index 115ab41416f..1669682cb2d 100644
--- a/packages/vue-router/tests/link.test.tsx
+++ b/packages/vue-router/tests/link.test.tsx
@@ -412,6 +412,220 @@ describe('Link', () => {
expect(vueCaseTouchstart).toHaveBeenCalledTimes(1)
})
+ test('reacts to internal and external destination changes', async () => {
+ const to = Vue.ref('/posts')
+ const target = Vue.ref()
+ const decorated = Vue.ref(false)
+ const disabled = Vue.ref(false)
+
+ const rootRoute = createRootRoute({
+ component: Vue.defineComponent({
+ setup() {
+ return () =>
+ Vue.h(
+ Link as any,
+ {
+ to: to.value,
+ target: target.value,
+ disabled: disabled.value,
+ ...(decorated.value
+ ? { class: 'decorated', 'aria-label': 'Updated link' }
+ : {}),
+ },
+ { default: () => 'Dynamic link' },
+ )
+ },
+ }),
+ })
+ const postsRoute = createRoute({
+ getParentRoute: () => rootRoute,
+ path: '/posts',
+ })
+ const aboutRoute = createRoute({
+ getParentRoute: () => rootRoute,
+ path: '/about',
+ })
+ const router = createRouter({
+ routeTree: rootRoute.addChildren([postsRoute, aboutRoute]),
+ history,
+ })
+ const navigateSpy = vi.spyOn(router, 'navigate')
+
+ render()
+
+ const link = await screen.findByRole('link', { name: 'Dynamic link' })
+ expect(link).toHaveAttribute('href', '/posts')
+ expect(link).not.toHaveClass('decorated')
+
+ to.value = 'https://example.com/one'
+ target.value = '_blank'
+ decorated.value = true
+ disabled.value = true
+ await Vue.nextTick()
+
+ expect(link).not.toHaveAttribute('href')
+ expect(link).toHaveAttribute('target', '_blank')
+ expect(link).toHaveAttribute('aria-label', 'Updated link')
+ expect(link).toHaveClass('decorated')
+ expect(link).toHaveAttribute('role', 'link')
+ expect(link).toHaveAttribute('aria-disabled', 'true')
+ expect(link).not.toHaveAttribute('disabled')
+
+ disabled.value = false
+ to.value = 'https://example.com/two'
+ await Vue.nextTick()
+ expect(link).toHaveAttribute('href', 'https://example.com/two')
+ expect(link).not.toHaveAttribute('aria-disabled')
+ expect(link).not.toHaveAttribute('disabled')
+
+ to.value = 'javascript:alert(1)'
+ await Vue.nextTick()
+ expect(link).not.toHaveAttribute('href')
+
+ to.value = 'https://example.com/three'
+ await Vue.nextTick()
+ expect(link).toHaveAttribute('href', 'https://example.com/three')
+
+ to.value = '/about'
+ target.value = undefined
+ decorated.value = false
+ disabled.value = false
+ await Vue.nextTick()
+
+ expect(link).toHaveAttribute('href', '/about')
+ expect(link).not.toHaveAttribute('target')
+ expect(link).not.toHaveAttribute('aria-label')
+ expect(link).not.toHaveClass('decorated')
+ expect(link).not.toHaveAttribute('aria-disabled')
+ expect(link).not.toHaveAttribute('disabled')
+
+ decorated.value = true
+ await Vue.nextTick()
+ expect(link).toHaveAttribute('aria-label', 'Updated link')
+ expect(link).toHaveClass('decorated')
+
+ decorated.value = false
+ await Vue.nextTick()
+ expect(link).not.toHaveAttribute('aria-label')
+ expect(link).not.toHaveClass('decorated')
+
+ await fireEvent.click(link)
+ expect(navigateSpy).toHaveBeenCalledOnce()
+ })
+
+ test('tracks router location after an external link becomes internal', async () => {
+ const to = Vue.ref('https://example.com')
+ const rootRoute = createRootRoute({
+ component: Vue.defineComponent({
+ setup() {
+ return () => (
+
+ Initially external
+
+ )
+ },
+ }),
+ })
+ const postsRoute = createRoute({
+ getParentRoute: () => rootRoute,
+ path: '/posts',
+ })
+ const router = createRouter({
+ routeTree: rootRoute.addChildren([postsRoute]),
+ history,
+ })
+
+ render()
+
+ const link = await screen.findByRole('link', { name: 'Initially external' })
+ expect(link).toHaveAttribute('href', 'https://example.com')
+
+ to.value = '/posts'
+ await Vue.nextTick()
+ expect(link).toHaveAttribute('href', '/posts')
+
+ await fireEvent.click(link)
+ await waitFor(() => {
+ expect(window.location.pathname).toBe('/posts')
+ expect(link).toHaveAttribute('data-status', 'active')
+ })
+ })
+
+ test('uses current event handlers after link props change', async () => {
+ const firstClick = vi.fn((event: MouseEvent) => event.preventDefault())
+ const secondClick = vi.fn((event: MouseEvent) => event.preventDefault())
+ const firstMouseEnter = vi.fn()
+ const secondMouseEnter = vi.fn()
+ const clickHandler = Vue.shallowRef<
+ ((event: MouseEvent) => void) | undefined
+ >(firstClick)
+ const mouseEnterHandler = Vue.shallowRef<
+ ((event: MouseEvent) => void) | undefined
+ >(firstMouseEnter)
+ const disabled = Vue.ref(false)
+
+ const rootRoute = createRootRoute({
+ component: Vue.defineComponent({
+ setup() {
+ return () => (
+
+ Dynamic handlers
+
+ )
+ },
+ }),
+ })
+ const postsRoute = createRoute({
+ getParentRoute: () => rootRoute,
+ path: '/posts',
+ })
+ const router = createRouter({
+ routeTree: rootRoute.addChildren([postsRoute]),
+ history,
+ })
+
+ render()
+
+ const link = await screen.findByRole('link', { name: 'Dynamic handlers' })
+ await fireEvent.click(link)
+ await fireEvent.mouseEnter(link)
+ expect(firstClick).toHaveBeenCalledOnce()
+ expect(firstMouseEnter).toHaveBeenCalledOnce()
+
+ clickHandler.value = secondClick
+ mouseEnterHandler.value = secondMouseEnter
+ await Vue.nextTick()
+ await fireEvent.click(link)
+ await fireEvent.mouseEnter(link)
+
+ expect(firstClick).toHaveBeenCalledOnce()
+ expect(firstMouseEnter).toHaveBeenCalledOnce()
+ expect(secondClick).toHaveBeenCalledOnce()
+ expect(secondMouseEnter).toHaveBeenCalledOnce()
+
+ clickHandler.value = undefined
+ mouseEnterHandler.value = undefined
+ await Vue.nextTick()
+ await fireEvent.click(link)
+ await fireEvent.mouseEnter(link)
+
+ expect(secondClick).toHaveBeenCalledOnce()
+ expect(secondMouseEnter).toHaveBeenCalledOnce()
+
+ disabled.value = true
+ await Vue.nextTick()
+ await fireEvent.click(link)
+ await fireEvent.mouseEnter(link)
+
+ expect(secondClick).toHaveBeenCalledOnce()
+ expect(secondMouseEnter).toHaveBeenCalledOnce()
+ })
+
describe('when the current route has a search fields with undefined values', () => {
async function runTest(opts: { explicitUndefined: boolean | undefined }) {
const rootRoute = createRootRoute()
@@ -5284,6 +5498,112 @@ describe('Link', () => {
expect(mock).toHaveBeenCalledTimes(1)
})
+ test('Link.preload="render" preloads each reactive destination', async () => {
+ const to = Vue.ref('/posts')
+ const rootRoute = createRootRoute({
+ component: Vue.defineComponent({
+ setup() {
+ return () => (
+
+ Dynamic render preload
+
+ )
+ },
+ }),
+ })
+ const postsRoute = createRoute({
+ getParentRoute: () => rootRoute,
+ path: '/posts',
+ })
+ const aboutRoute = createRoute({
+ getParentRoute: () => rootRoute,
+ path: '/about',
+ })
+ const router = createRouter({
+ routeTree: rootRoute.addChildren([postsRoute, aboutRoute]),
+ history,
+ })
+ const preloadRouteSpy = vi.spyOn(router, 'preloadRoute')
+
+ render()
+
+ const link = await screen.findByRole('link', {
+ name: 'Dynamic render preload',
+ })
+ await waitFor(() => expect(preloadRouteSpy).toHaveBeenCalledOnce())
+ expect(link).toHaveAttribute('href', '/posts')
+
+ to.value = '/about'
+ await waitFor(() => expect(preloadRouteSpy).toHaveBeenCalledTimes(2))
+ expect(link).toHaveAttribute('href', '/about')
+ })
+
+ test('cancels stale delayed preloads after link inputs change', async () => {
+ const to = Vue.ref('/posts')
+ const disabled = Vue.ref(false)
+ const rootRoute = createRootRoute({
+ component: Vue.defineComponent({
+ setup() {
+ return () => (
+
+ Dynamic intent preload
+
+ )
+ },
+ }),
+ })
+ const postsRoute = createRoute({
+ getParentRoute: () => rootRoute,
+ path: '/posts',
+ })
+ const aboutRoute = createRoute({
+ getParentRoute: () => rootRoute,
+ path: '/about',
+ })
+ const router = createRouter({
+ routeTree: rootRoute.addChildren([postsRoute, aboutRoute]),
+ history,
+ })
+ const preloadRouteSpy = vi.spyOn(router, 'preloadRoute')
+
+ render()
+ const link = await screen.findByRole('link', {
+ name: 'Dynamic intent preload',
+ })
+ vi.useFakeTimers()
+
+ await fireEvent.mouseEnter(link)
+ disabled.value = true
+ await Vue.nextTick()
+ await vi.advanceTimersByTimeAsync(50)
+ expect(preloadRouteSpy).not.toHaveBeenCalled()
+
+ disabled.value = false
+ await Vue.nextTick()
+ await fireEvent.mouseEnter(link)
+ to.value = '/about'
+ await Vue.nextTick()
+ await vi.advanceTimersByTimeAsync(50)
+ expect(preloadRouteSpy).not.toHaveBeenCalled()
+
+ await fireEvent.mouseEnter(link)
+ to.value = 'https://example.com'
+ await Vue.nextTick()
+ await vi.advanceTimersByTimeAsync(50)
+ expect(preloadRouteSpy).not.toHaveBeenCalled()
+
+ to.value = '/about'
+ await Vue.nextTick()
+ await fireEvent.mouseEnter(link)
+ await vi.advanceTimersByTimeAsync(50)
+ expect(preloadRouteSpy).toHaveBeenCalledOnce()
+ })
+
test.each([undefined, false, 'render', 'viewport'] as const)(
'Link.preload="%s" should not preload on focus, hover, or touchstart',
async (preloadMode) => {
diff --git a/packages/vue-router/tests/remountDeps.test.tsx b/packages/vue-router/tests/remountDeps.test.tsx
new file mode 100644
index 00000000000..ff9bf6808e3
--- /dev/null
+++ b/packages/vue-router/tests/remountDeps.test.tsx
@@ -0,0 +1,100 @@
+import * as Vue from 'vue'
+import { cleanup, render, screen } from '@testing-library/vue'
+import { afterEach, expect, test, vi } from 'vitest'
+import {
+ Outlet,
+ RouterProvider,
+ createMemoryHistory,
+ createRootRoute,
+ createRoute,
+ createRouter,
+} from '../src'
+
+afterEach(() => {
+ cleanup()
+})
+
+function setup(remountOnParams: boolean | 'falsy' = false) {
+ const mounted = vi.fn()
+ const unmounted = vi.fn()
+ const rootRoute = createRootRoute({ component: () => })
+ const ItemComponent = Vue.defineComponent({
+ name: 'ItemComponent',
+ setup() {
+ const params = itemRoute.useParams()
+
+ Vue.onMounted(mounted)
+ Vue.onUnmounted(unmounted)
+
+ return () => Item {params.value.itemId}
+ },
+ })
+ const itemRoute = createRoute({
+ getParentRoute: () => rootRoute,
+ path: '/items/$itemId',
+ component: ItemComponent,
+ remountDeps:
+ remountOnParams === 'falsy'
+ ? ({ params }) => (params.itemId === 'one' ? false : 0)
+ : remountOnParams
+ ? ({ params }) => params
+ : undefined,
+ })
+ const router = createRouter({
+ routeTree: rootRoute.addChildren([itemRoute]),
+ history: createMemoryHistory({ initialEntries: ['/items/one'] }),
+ })
+
+ render()
+
+ return { mounted, router, unmounted }
+}
+
+async function navigateToSecondItem(
+ router: ReturnType['router'],
+) {
+ await router.navigate({
+ to: '/items/$itemId',
+ params: { itemId: 'two' },
+ })
+}
+
+test('keeps an active route component mounted when params change by default', async () => {
+ const { mounted, router, unmounted } = setup()
+
+ expect(await screen.findByText('Item one')).toBeInTheDocument()
+ expect(mounted).toHaveBeenCalledTimes(1)
+ expect(unmounted).not.toHaveBeenCalled()
+
+ await navigateToSecondItem(router)
+
+ expect(await screen.findByText('Item two')).toBeInTheDocument()
+ expect(mounted).toHaveBeenCalledTimes(1)
+ expect(unmounted).not.toHaveBeenCalled()
+})
+
+test('remounts an active route component when params are remount deps', async () => {
+ const { mounted, router, unmounted } = setup(true)
+
+ expect(await screen.findByText('Item one')).toBeInTheDocument()
+ expect(mounted).toHaveBeenCalledTimes(1)
+
+ await navigateToSecondItem(router)
+
+ expect(await screen.findByText('Item two')).toBeInTheDocument()
+ expect(mounted).toHaveBeenCalledTimes(2)
+ expect(unmounted).toHaveBeenCalledTimes(1)
+})
+
+test('remounts when remount deps change between falsy values', async () => {
+ const { mounted, router, unmounted } = setup('falsy')
+
+ expect(await screen.findByText('Item one')).toBeInTheDocument()
+ expect(mounted).toHaveBeenCalledTimes(1)
+
+ await navigateToSecondItem(router)
+
+ expect(await screen.findByText('Item two')).toBeInTheDocument()
+ expect(mounted).toHaveBeenCalledTimes(2)
+ expect(unmounted).toHaveBeenCalledTimes(1)
+})