From 9856f5fcd5af0ea582ce40bb2fb7b808857bdcd0 Mon Sep 17 00:00:00 2001 From: Sheraff Date: Sat, 8 Aug 2026 10:00:48 +0200 Subject: [PATCH 1/3] Fix default route component remounting --- packages/solid-router/src/Match.tsx | 2 +- .../solid-router/tests/remountDeps.test.tsx | 81 ++++++++++++++++++ packages/vue-router/src/Match.tsx | 19 ++--- packages/vue-router/src/link.tsx | 4 +- .../vue-router/tests/remountDeps.test.tsx | 82 +++++++++++++++++++ 5 files changed, 171 insertions(+), 17 deletions(-) create mode 100644 packages/solid-router/tests/remountDeps.test.tsx create mode 100644 packages/vue-router/tests/remountDeps.test.tsx diff --git a/packages/solid-router/src/Match.tsx b/packages/solid-router/src/Match.tsx index 16038453c03..bc99f9418fb 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 00000000000..cac0450f08a --- /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 96d81970a51..07baf01817e 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 75c1901e4e6..61063bc19e5 100644 --- a/packages/vue-router/src/link.tsx +++ b/packages/vue-router/src/link.tsx @@ -859,8 +859,8 @@ const LinkImpl = Vue.defineComponent({ 'target', ], setup(props, { attrs, slots }) { - // Call useLinkProps ONCE during setup with combined props and attrs - const allProps = { ...props, ...attrs } + // Keep declared props reactive when the owning route component is reused. + const allProps = Vue.proxyRefs({ ...Vue.toRefs(props), ...attrs }) const linkPropsSource = useLinkProps(allProps) 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 00000000000..56ccd6f24d8 --- /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) +}) From 9cb14ab6efaf1a376a5daeb5b7c1b8c8ccbbd9ab Mon Sep 17 00:00:00 2001 From: Sheraff Date: Sat, 8 Aug 2026 11:31:36 +0200 Subject: [PATCH 2/3] Optimize reactive Vue Link props --- packages/vue-router/src/link.tsx | 99 ++++++++++++++++++++++---------- 1 file changed, 68 insertions(+), 31 deletions(-) diff --git a/packages/vue-router/src/link.tsx b/packages/vue-router/src/link.tsx index 61063bc19e5..892cd14150c 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 }) { - // Keep declared props reactive when the owning route component is reused. - const allProps = Vue.proxyRefs({ ...Vue.toRefs(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 From 5ec9a201df1e3f39dcbe7899c30be2cd4eb7b88d Mon Sep 17 00:00:00 2001 From: Sheraff Date: Wed, 12 Aug 2026 13:12:26 +0200 Subject: [PATCH 3/3] fix(vue-router): address reactive Link review feedback --- packages/vue-router/src/link.tsx | 24 ++++++++++++------- packages/vue-router/tests/link.test.tsx | 31 +++++++++++++++++++++---- 2 files changed, 42 insertions(+), 13 deletions(-) diff --git a/packages/vue-router/src/link.tsx b/packages/vue-router/src/link.tsx index 3a415f10702..ccff4e90060 100644 --- a/packages/vue-router/src/link.tsx +++ b/packages/vue-router/src/link.tsx @@ -84,7 +84,9 @@ type PropsOfComponent = ? P : Record -type AnyLinkPropsOptions = UseLinkPropsOptions +type AnyLinkPropsOptions = UseLinkPropsOptions & { + _asChild?: unknown +} type LinkEventOptions = AnyLinkPropsOptions & Partial export function useLinkProps< @@ -175,6 +177,8 @@ function useLinkPropsImpl( 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') { @@ -235,6 +239,8 @@ function useLinkPropsImpl( }) } + let pendingPreload: 'intent' | 'viewport' | undefined + const enqueuePreload = ( e?: MouseEvent | FocusEvent | IntersectionObserverEntry, ) => { @@ -280,15 +286,13 @@ function useLinkPropsImpl( } } - let pendingPreload: 'intent' | 'viewport' | undefined - useIntersectionObserver( ref, enqueuePreload, () => preload.value !== 'viewport', ) - Vue.effect(() => { + Vue.watchEffect(() => { if (preload.value !== 'render') { return } @@ -508,7 +512,7 @@ function combineResultProps({ ref, ...staticEventHandlers, href, - disabled: !!options.disabled, + disabled: options._asChild ? !!options.disabled : undefined, target: options.target, } @@ -562,10 +566,9 @@ function getExternalLinkProps( const result: Record = { ...getPropsSafeToSpread(options), ref, - ...staticEventHandlers, - href: dangerous ? undefined : options.to, + href: dangerous || options.disabled ? undefined : options.to, target: options.target, - disabled: options.disabled, + disabled: options._asChild ? !!options.disabled : undefined, style: options.style, class: options.class, onClick: staticEventHandlers?.onClick ?? options.onClick, @@ -581,6 +584,11 @@ function getExternalLinkProps( 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] diff --git a/packages/vue-router/tests/link.test.tsx b/packages/vue-router/tests/link.test.tsx index a303fc4e575..1669682cb2d 100644 --- a/packages/vue-router/tests/link.test.tsx +++ b/packages/vue-router/tests/link.test.tsx @@ -463,15 +463,20 @@ describe('Link', () => { disabled.value = true await Vue.nextTick() - expect(link).toHaveAttribute('href', 'https://example.com/one') + expect(link).not.toHaveAttribute('href') expect(link).toHaveAttribute('target', '_blank') expect(link).toHaveAttribute('aria-label', 'Updated link') expect(link).toHaveClass('decorated') - expect(link).toHaveAttribute('disabled') + 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() @@ -491,7 +496,8 @@ describe('Link', () => { expect(link).not.toHaveAttribute('target') expect(link).not.toHaveAttribute('aria-label') expect(link).not.toHaveClass('decorated') - expect(link).toHaveAttribute('disabled', 'false') + expect(link).not.toHaveAttribute('aria-disabled') + expect(link).not.toHaveAttribute('disabled') decorated.value = true await Vue.nextTick() @@ -539,8 +545,10 @@ describe('Link', () => { expect(link).toHaveAttribute('href', '/posts') await fireEvent.click(link) - await waitFor(() => expect(window.location.pathname).toBe('/posts')) - expect(link).toHaveAttribute('data-status', 'active') + await waitFor(() => { + expect(window.location.pathname).toBe('/posts') + expect(link).toHaveAttribute('data-status', 'active') + }) }) test('uses current event handlers after link props change', async () => { @@ -602,6 +610,13 @@ describe('Link', () => { 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) @@ -5581,6 +5596,12 @@ describe('Link', () => { 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)(