From 9856f5fcd5af0ea582ce40bb2fb7b808857bdcd0 Mon Sep 17 00:00:00 2001 From: Sheraff Date: Sat, 8 Aug 2026 10:00:48 +0200 Subject: [PATCH 1/2] 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 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..61063bc19e 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 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) +}) From 9cb14ab6efaf1a376a5daeb5b7c1b8c8ccbbd9ab Mon Sep 17 00:00:00 2001 From: Sheraff Date: Sat, 8 Aug 2026 11:31:36 +0200 Subject: [PATCH 2/2] 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 61063bc19e..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 }) { - // 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