Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated

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
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
| LinkHTMLAttributes
| Vue.ComputedRef<LinkHTMLAttributes>

Expand Down
Loading
Loading