diff --git a/packages/solid-router/src/Match.tsx b/packages/solid-router/src/Match.tsx
index 16038453c0..bc99f9418f 100644
--- a/packages/solid-router/src/Match.tsx
+++ b/packages/solid-router/src/Match.tsx
@@ -174,7 +174,7 @@ export const MatchInner = (): any => {
params: current._strictParams,
search: current._strictSearch,
})
- return deps ? JSON.stringify(deps) : current.id
+ return deps ? 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 0000000000..cac0450f08
--- /dev/null
+++ b/packages/solid-router/tests/remountDeps.test.tsx
@@ -0,0 +1,81 @@
+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 = 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 ? ({ 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)
+})
diff --git a/packages/vue-router/src/Match.tsx b/packages/vue-router/src/Match.tsx
index 96d81970a5..07baf01817 100644
--- a/packages/vue-router/src/Match.tsx
+++ b/packages/vue-router/src/Match.tsx
@@ -256,17 +256,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 => {
@@ -274,17 +268,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 75c1901e4e..892cd14150 100644
--- a/packages/vue-router/src/link.tsx
+++ b/packages/vue-router/src/link.tsx
@@ -84,6 +84,12 @@ export function useLinkProps<
TMaskTo extends string = '',
>(
options: UseLinkPropsOptions,
+): LinkHTMLAttributes {
+ return useLinkPropsImpl(() => options as AnyLinkPropsOptions)
+}
+
+function useLinkPropsImpl(
+ getOptions: () => AnyLinkPropsOptions,
): LinkHTMLAttributes {
const router = useRouter()
const isTransitioning = Vue.ref(false)
@@ -97,6 +103,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'
@@ -106,9 +113,11 @@ export function useLinkProps<
})
const ref = Vue.ref(null)
- const eventHandlers = getLinkEventHandlers(options as LinkEventOptions)
+ const initialOptions = getOptions()
+ const eventHandlers = getLinkEventHandlers(initialOptions as LinkEventOptions)
if (type.value === 'external') {
+ const options = getOptions()
// Block dangerous protocols like javascript:, blob:, data:
if (isDangerousProtocol(options.to as string, router.protocolAllowlist)) {
if (process.env.NODE_ENV !== 'production') {
@@ -116,7 +125,7 @@ export function useLinkProps<
}
// Return props without href to prevent navigation
const safeProps: Record = {
- ...getPropsSafeToSpread(options as AnyLinkPropsOptions),
+ ...getPropsSafeToSpread(options),
ref,
// No href attribute - blocks the dangerous protocol
target: options.target,
@@ -147,7 +156,7 @@ export function useLinkProps<
// External links just have simple props
const externalProps: Record = {
- ...getPropsSafeToSpread(options as AnyLinkPropsOptions),
+ ...getPropsSafeToSpread(options),
ref,
href: options.to,
target: options.target,
@@ -179,8 +188,9 @@ export function useLinkProps<
// 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()
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(),
@@ -194,11 +204,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,
isTransitioning: false,
resolvedActiveProps,
@@ -219,11 +229,13 @@ export function useLinkProps<
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(() => {
+ const options = getOptions()
if (options.reloadDocument) {
return false
}
@@ -231,25 +243,28 @@ export function useLinkProps<
})
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)
})
+ }
const preloadViewportIoCallback = (
entry: IntersectionObserverEntry | undefined,
@@ -263,13 +278,14 @@ export function useLinkProps<
ref,
preloadViewportIoCallback,
{ rootMargin: '100px' },
- () => !!options.disabled || preload.value !== 'viewport',
+ () => !!getOptions().disabled || preload.value !== 'viewport',
)
Vue.effect(() => {
if (hasRenderFetched) {
return
}
+ const options = getOptions()
if (!options.disabled && preload.value === 'render') {
doPreload()
hasRenderFetched = true
@@ -278,6 +294,7 @@ export function useLinkProps<
// The click handler
const handleClick = (e: PointerEvent): void => {
+ const options = getOptions()
// Check actual element's target attribute as fallback
const elementTarget = (
e.currentTarget as HTMLAnchorElement | SVGAElement
@@ -320,7 +337,10 @@ export function useLinkProps<
}
const enqueueIntentPreload = (e: MouseEvent | FocusEvent) => {
- if (options.disabled || preload.value !== 'intent') return
+ const options = getOptions()
+ if (options.disabled || preload.value !== 'intent') {
+ return
+ }
if (!preloadDelay.value) {
doPreload()
@@ -329,7 +349,9 @@ export function useLinkProps<
const eventTarget = e.currentTarget || e.target
- if (!eventTarget || timeoutMap.has(eventTarget)) return
+ if (!eventTarget || timeoutMap.has(eventTarget)) {
+ return
+ }
timeoutMap.set(
eventTarget,
@@ -341,12 +363,17 @@ export function useLinkProps<
}
const handleTouchStart = (_: TouchEvent) => {
- if (options.disabled || preload.value !== 'intent') return
+ const options = getOptions()
+ if (options.disabled || preload.value !== 'intent') {
+ return
+ }
doPreload()
}
const handleLeave = (e: MouseEvent | FocusEvent) => {
- if (options.disabled) return
+ if (getOptions().disabled) {
+ return
+ }
const eventTarget = e.currentTarget || e.target
if (eventTarget) {
@@ -370,20 +397,28 @@ export function useLinkProps<
}
// 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]),
+ onClick: composeEventHandlers([
+ initialOptions.onClick,
+ handleClick,
+ ]),
+ onBlur: composeEventHandlers([
+ initialOptions.onBlur,
+ handleLeave,
+ ]),
onFocus: composeEventHandlers([
- options.onFocus,
+ initialOptions.onFocus,
enqueueIntentPreload,
]),
onMouseenter: composeEventHandlers([
@@ -411,6 +446,7 @@ export function useLinkProps<
// 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()
const {
resolvedActiveProps,
resolvedInactiveProps,
@@ -419,7 +455,7 @@ export function useLinkProps<
} = resolvedStyleProps.value
return combineResultProps({
href: href.value,
- options: options as AnyLinkPropsOptions,
+ options,
ref,
staticEventHandlers,
isActive: isActive.value,
@@ -859,9 +895,10 @@ 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
+ // Cache a plain snapshot until an input prop changes. This keeps Link
+ // reactive without proxy/ref work in every location-driven computation.
+ const allProps = Vue.computed(() => ({ ...props, ...attrs }))
+ const linkPropsSource = useLinkPropsImpl(() => allProps.value) as
| LinkHTMLAttributes
| Vue.ComputedRef
diff --git a/packages/vue-router/tests/remountDeps.test.tsx b/packages/vue-router/tests/remountDeps.test.tsx
new file mode 100644
index 0000000000..56ccd6f24d
--- /dev/null
+++ b/packages/vue-router/tests/remountDeps.test.tsx
@@ -0,0 +1,82 @@
+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 = 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 ? ({ 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)
+})