Skip to content

Commit 0b483f9

Browse files
committed
feat(dx): warn when passing undefined/null locations
1 parent 13303bd commit 0b483f9

File tree

6 files changed

+235
-6
lines changed

6 files changed

+235
-6
lines changed

packages/router/__tests__/router.spec.ts

+16
Original file line numberDiff line numberDiff line change
@@ -330,6 +330,22 @@ describe('Router', () => {
330330
expect(route1.params).toEqual({ p: 'a' })
331331
})
332332

333+
it('handles an undefined location', async () => {
334+
const { router } = await newRouter()
335+
336+
const route1 = router.resolve(undefined as any)
337+
expect('router.resolve() was passed an invalid location').toHaveBeenWarned()
338+
expect(route1.path).toBe('/')
339+
})
340+
341+
it('handles a null location', async () => {
342+
const { router } = await newRouter()
343+
344+
const route1 = router.resolve(null as any)
345+
expect('router.resolve() was passed an invalid location').toHaveBeenWarned()
346+
expect(route1.path).toBe('/')
347+
})
348+
333349
it('removes null/undefined optional params when current location has it', async () => {
334350
const { router } = await newRouter()
335351

+148
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,148 @@
1+
/**
2+
* @jest-environment jsdom
3+
*/
4+
import { nextTick, ref } from 'vue'
5+
import { mount } from '@vue/test-utils'
6+
import { mockWarn } from 'jest-mock-warn'
7+
import {
8+
createMemoryHistory,
9+
createRouter,
10+
routeLocationKey,
11+
RouteLocationRaw,
12+
routerKey,
13+
useLink,
14+
UseLinkOptions,
15+
} from '../src'
16+
17+
async function callUseLink(args: UseLinkOptions) {
18+
const router = createRouter({
19+
history: createMemoryHistory(),
20+
routes: [
21+
{
22+
path: '/',
23+
component: {},
24+
name: 'root',
25+
},
26+
{
27+
path: '/a',
28+
component: {},
29+
name: 'a',
30+
},
31+
{
32+
path: '/b',
33+
component: {},
34+
name: 'b',
35+
},
36+
],
37+
})
38+
39+
await router.push('/')
40+
41+
let link: ReturnType<typeof useLink>
42+
43+
mount(
44+
{
45+
setup() {
46+
link = useLink(args)
47+
48+
return () => ''
49+
},
50+
},
51+
{
52+
global: {
53+
provide: {
54+
[routerKey as any]: router,
55+
[routeLocationKey as any]: router.currentRoute.value,
56+
},
57+
},
58+
}
59+
)
60+
61+
return link!
62+
}
63+
64+
describe('useLink', () => {
65+
describe('basic usage', () => {
66+
it('supports a string for "to"', async () => {
67+
const { href, route } = await callUseLink({
68+
to: '/a',
69+
})
70+
71+
expect(href.value).toBe('/a')
72+
expect(route.value).toMatchObject({ name: 'a' })
73+
})
74+
75+
it('supports an object for "to"', async () => {
76+
const { href, route } = await callUseLink({
77+
to: { path: '/a' },
78+
})
79+
80+
expect(href.value).toBe('/a')
81+
expect(route.value).toMatchObject({ name: 'a' })
82+
})
83+
84+
it('supports a ref for "to"', async () => {
85+
const to = ref<RouteLocationRaw>('/a')
86+
87+
const { href, route } = await callUseLink({
88+
to,
89+
})
90+
91+
expect(href.value).toBe('/a')
92+
expect(route.value).toMatchObject({ name: 'a' })
93+
94+
to.value = { path: '/b' }
95+
96+
await nextTick()
97+
98+
expect(href.value).toBe('/b')
99+
expect(route.value).toMatchObject({ name: 'b' })
100+
})
101+
})
102+
103+
describe('warnings', () => {
104+
mockWarn()
105+
106+
it('should warn when "to" is undefined', async () => {
107+
await callUseLink({
108+
to: undefined as any,
109+
})
110+
111+
expect('Invalid value for prop "to" in useLink()').toHaveBeenWarned()
112+
expect(
113+
'router.resolve() was passed an invalid location'
114+
).toHaveBeenWarned()
115+
})
116+
117+
it('should warn when "to" is an undefined ref', async () => {
118+
await callUseLink({
119+
to: ref(undefined as any),
120+
})
121+
122+
expect('Invalid value for prop "to" in useLink()').toHaveBeenWarned()
123+
expect(
124+
'router.resolve() was passed an invalid location'
125+
).toHaveBeenWarned()
126+
})
127+
128+
it('should warn when "to" changes to a null ref', async () => {
129+
const to = ref('/a')
130+
131+
const { href, route } = await callUseLink({
132+
to,
133+
})
134+
135+
expect(href.value).toBe('/a')
136+
expect(route.value).toMatchObject({ name: 'a' })
137+
138+
to.value = null as any
139+
140+
await nextTick()
141+
142+
expect('Invalid value for prop "to" in useLink()').toHaveBeenWarned()
143+
expect(
144+
'router.resolve() was passed an invalid location'
145+
).toHaveBeenWarned()
146+
})
147+
})
148+
})

packages/router/src/RouterLink.ts

+41-2
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,8 @@ import { isSameRouteLocationParams, isSameRouteRecord } from './location'
3636
import { routerKey, routeLocationKey } from './injectionSymbols'
3737
import { RouteRecord } from './matcher/types'
3838
import { NavigationFailure } from './errors'
39-
import { isArray, isBrowser, noop } from './utils'
39+
import { isArray, isBrowser, isObject, noop } from './utils'
40+
import { warn } from './warning'
4041

4142
export interface RouterLinkOptions {
4243
/**
@@ -83,6 +84,7 @@ export interface UseLinkDevtoolsContext {
8384
route: RouteLocationNormalized & { href: string }
8485
isActive: boolean
8586
isExactActive: boolean
87+
error: string
8688
}
8789

8890
export type UseLinkOptions = VueUseOptions<RouterLinkOptions>
@@ -93,7 +95,40 @@ export function useLink(props: UseLinkOptions) {
9395
const router = inject(routerKey)!
9496
const currentRoute = inject(routeLocationKey)!
9597

96-
const route = computed(() => router.resolve(unref(props.to)))
98+
const isValidTo = (to: unknown) => typeof to === 'string' || isObject(to)
99+
let hasPrevious = false
100+
let previousTo: unknown = null
101+
102+
const route = computed(() => {
103+
const to = unref(props.to)
104+
105+
if (__DEV__ && (!hasPrevious || to !== previousTo)) {
106+
if (!isValidTo(to)) {
107+
if (hasPrevious) {
108+
warn(
109+
`Invalid value for prop "to" in useLink()\n- to:`,
110+
to,
111+
`\n- previous to:`,
112+
previousTo,
113+
`\n- props:`,
114+
props
115+
)
116+
} else {
117+
warn(
118+
`Invalid value for prop "to" in useLink()\n- to:`,
119+
to,
120+
`\n- props:`,
121+
props
122+
)
123+
}
124+
}
125+
126+
previousTo = to
127+
hasPrevious = true
128+
}
129+
130+
return router.resolve(to)
131+
})
97132

98133
const activeRecordIndex = computed<number>(() => {
99134
const { matched } = route.value
@@ -157,6 +192,7 @@ export function useLink(props: UseLinkOptions) {
157192
route: route.value,
158193
isActive: isActive.value,
159194
isExactActive: isExactActive.value,
195+
error: '',
160196
}
161197

162198
// @ts-expect-error: this is internal
@@ -168,6 +204,9 @@ export function useLink(props: UseLinkOptions) {
168204
linkContextDevtools.route = route.value
169205
linkContextDevtools.isActive = isActive.value
170206
linkContextDevtools.isExactActive = isExactActive.value
207+
linkContextDevtools.error = isValidTo(unref(props.to))
208+
? ''
209+
: 'Invalid "to" value'
171210
},
172211
{ flush: 'post' }
173212
)

packages/router/src/devtools.ts

+11-3
Original file line numberDiff line numberDiff line change
@@ -119,10 +119,16 @@ export function addDevtools(app: App, router: Router, matcher: RouterMatcher) {
119119
;(
120120
componentInstance.__vrl_devtools as UseLinkDevtoolsContext[]
121121
).forEach(devtoolsData => {
122+
let label = devtoolsData.route.path
122123
let backgroundColor = ORANGE_400
123124
let tooltip: string = ''
125+
let textColor = 0
124126

125-
if (devtoolsData.isExactActive) {
127+
if (devtoolsData.error) {
128+
label = devtoolsData.error
129+
backgroundColor = RED_100
130+
textColor = RED_700
131+
} else if (devtoolsData.isExactActive) {
126132
backgroundColor = LIME_500
127133
tooltip = 'This is exactly active'
128134
} else if (devtoolsData.isActive) {
@@ -131,8 +137,8 @@ export function addDevtools(app: App, router: Router, matcher: RouterMatcher) {
131137
}
132138

133139
node.tags.push({
134-
label: devtoolsData.route.path,
135-
textColor: 0,
140+
label,
141+
textColor,
136142
tooltip,
137143
backgroundColor,
138144
})
@@ -419,6 +425,8 @@ const CYAN_400 = 0x22d3ee
419425
const ORANGE_400 = 0xfb923c
420426
// const GRAY_100 = 0xf4f4f5
421427
const DARK = 0x666666
428+
const RED_100 = 0xfee2e2
429+
const RED_700 = 0xb91c1c
422430

423431
function formatRouteRecordForInspector(
424432
route: RouteRecordMatcher

packages/router/src/router.ts

+16-1
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,14 @@ import {
3232
NavigationRedirectError,
3333
isNavigationFailure,
3434
} from './errors'
35-
import { applyToParams, isBrowser, assign, noop, isArray } from './utils'
35+
import {
36+
applyToParams,
37+
isBrowser,
38+
assign,
39+
noop,
40+
isArray,
41+
isObject,
42+
} from './utils'
3643
import { useCallbacks } from './utils/callbacks'
3744
import { encodeParam, decode, encodeHash } from './encoding'
3845
import {
@@ -460,6 +467,14 @@ export function createRouter(options: RouterOptions): Router {
460467
})
461468
}
462469

470+
if (__DEV__ && !isObject(rawLocation)) {
471+
warn(
472+
`router.resolve() was passed an invalid location. This will fail in production.\n- Location:`,
473+
rawLocation
474+
)
475+
rawLocation = {}
476+
}
477+
463478
let matcherLocation: MatcherLocationRaw
464479

465480
// path could be relative in object as well

packages/router/src/utils/index.ts

+3
Original file line numberDiff line numberDiff line change
@@ -37,3 +37,6 @@ export const noop = () => {}
3737
*/
3838
export const isArray: (arg: ArrayLike<any> | any) => arg is ReadonlyArray<any> =
3939
Array.isArray
40+
41+
export const isObject = (val: unknown): val is Record<any, any> =>
42+
val !== null && typeof val === 'object'

0 commit comments

Comments
 (0)