Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion packages/solid-router/src/Match.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 = () => {
Expand Down
81 changes: 81 additions & 0 deletions packages/solid-router/tests/remountDeps.test.tsx
Original file line number Diff line number Diff line change
@@ -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: () => <Outlet /> })

function ItemComponent() {
const params = itemRoute.useParams()

Solid.onMount(mounted)
Solid.onCleanup(unmounted)

return <div>Item {params().itemId}</div>
}

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(() => <RouterProvider router={router} />)

return { mounted, router, unmounted }
}

async function navigateToSecondItem(
router: ReturnType<typeof setup>['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)
})
19 changes: 5 additions & 14 deletions packages/vue-router/src/Match.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -256,35 +256,26 @@ 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 => {
if (parentMatch.value?._notFound) {
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.
Expand Down
99 changes: 68 additions & 31 deletions packages/vue-router/src/link.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,12 @@ export function useLinkProps<
TMaskTo extends string = '',
>(
options: UseLinkPropsOptions<TRouter, TFrom, TTo, TMaskFrom, TMaskTo>,
): LinkHTMLAttributes {
return useLinkPropsImpl(() => options as AnyLinkPropsOptions)
}

function useLinkPropsImpl(
getOptions: () => AnyLinkPropsOptions,
): LinkHTMLAttributes {
const router = useRouter()
const isTransitioning = Vue.ref(false)
Expand All @@ -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'
Expand All @@ -106,17 +113,19 @@ export function useLinkProps<
})

const ref = Vue.ref<Element | null>(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') {
console.warn(`Blocked Link with dangerous protocol: ${options.to}`)
}
// Return props without href to prevent navigation
const safeProps: Record<string, unknown> = {
...getPropsSafeToSpread(options as AnyLinkPropsOptions),
...getPropsSafeToSpread(options),
ref,
// No href attribute - blocks the dangerous protocol
target: options.target,
Expand Down Expand Up @@ -147,7 +156,7 @@ export function useLinkProps<

// External links just have simple props
const externalProps: Record<string, unknown> = {
...getPropsSafeToSpread(options as AnyLinkPropsOptions),
...getPropsSafeToSpread(options),
ref,
href: options.to,
target: options.target,
Expand Down Expand Up @@ -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(),
Expand All @@ -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,
Expand All @@ -219,37 +229,42 @@ 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
}
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)
})
}

const preloadViewportIoCallback = (
entry: IntersectionObserverEntry | undefined,
Expand All @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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()
Expand All @@ -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,
Expand All @@ -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) {
Expand All @@ -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<PointerEvent>([options.onClick, handleClick]),
onBlur: composeEventHandlers<FocusEvent>([options.onBlur, handleLeave]),
onClick: composeEventHandlers<PointerEvent>([
initialOptions.onClick,
handleClick,
]),
onBlur: composeEventHandlers<FocusEvent>([
initialOptions.onBlur,
handleLeave,
]),
onFocus: composeEventHandlers<FocusEvent>([
options.onFocus,
initialOptions.onFocus,
enqueueIntentPreload,
]),
onMouseenter: composeEventHandlers<MouseEvent>([
Expand Down Expand Up @@ -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<LinkHTMLAttributes>(() => {
const options = getOptions()
const {
resolvedActiveProps,
resolvedInactiveProps,
Expand All @@ -419,7 +455,7 @@ export function useLinkProps<
} = resolvedStyleProps.value
return combineResultProps({
href: href.value,
options: options as AnyLinkPropsOptions,
options,
ref,
staticEventHandlers,
isActive: isActive.value,
Expand Down Expand Up @@ -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<LinkHTMLAttributes>

Expand Down
Loading
Loading