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) +})