Skip to content

Commit 9de709a

Browse files
committed
✨ add Vue Router v4 integration
1 parent 9a4e80e commit 9de709a

5 files changed

Lines changed: 179 additions & 1 deletion

File tree

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
import { display } from '@datadog/browser-core'
2+
import type { RouteLocationMatched } from 'vue-router'
3+
import { initializeVuePlugin } from '../../../test/initializeVuePlugin'
4+
import { startVueRouterView, computeViewName } from './startVueRouterView'
5+
6+
describe('startVueRouterView', () => {
7+
it('starts a new view with the computed view name', () => {
8+
const startViewSpy = jasmine.createSpy()
9+
initializeVuePlugin({
10+
configuration: { router: true },
11+
publicApi: { startView: startViewSpy },
12+
})
13+
14+
startVueRouterView([{ path: '/' }, { path: 'user' }, { path: ':id' }] as unknown as RouteLocationMatched[])
15+
16+
expect(startViewSpy).toHaveBeenCalledOnceWith('/user/:id')
17+
})
18+
19+
it('warns if router: true is missing from plugin config', () => {
20+
const warnSpy = spyOn(display, 'warn')
21+
initializeVuePlugin({ configuration: {} })
22+
startVueRouterView([] as unknown as RouteLocationMatched[])
23+
expect(warnSpy).toHaveBeenCalledOnceWith(
24+
'`router: true` is missing from the vue plugin configuration, the view will not be tracked.'
25+
)
26+
})
27+
})
28+
29+
describe('computeViewName', () => {
30+
it('returns empty string for empty matched array', () => {
31+
expect(computeViewName([])).toBe('')
32+
})
33+
34+
it('returns the path for a simple route', () => {
35+
expect(computeViewName([{ path: '/users' }] as unknown as RouteLocationMatched[])).toBe('/users')
36+
})
37+
38+
it('returns the most specific matched path for nested routes', () => {
39+
// Vue Router normalizes nested matched paths to absolute paths
40+
expect(computeViewName([{ path: '/users' }, { path: '/users/:id' }] as unknown as RouteLocationMatched[])).toBe(
41+
'/users/:id'
42+
)
43+
})
44+
45+
it('ignores records without a path', () => {
46+
expect(
47+
computeViewName([{ path: '/users' }, { path: '' }, { path: '/users/:id' }] as unknown as RouteLocationMatched[])
48+
).toBe('/users/:id')
49+
})
50+
})
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
import { display } from '@datadog/browser-core'
2+
import type { RouteLocationMatched } from 'vue-router'
3+
import { onVueInit } from '../vuePlugin'
4+
5+
export function startVueRouterView(matched: RouteLocationMatched[]) {
6+
onVueInit((configuration, rumPublicApi) => {
7+
if (!configuration.router) {
8+
display.warn('`router: true` is missing from the vue plugin configuration, the view will not be tracked.')
9+
return
10+
}
11+
rumPublicApi.startView(computeViewName(matched))
12+
})
13+
}
14+
15+
export function computeViewName(matched: RouteLocationMatched[]): string {
16+
if (!matched || matched.length === 0) {
17+
return ''
18+
}
19+
20+
let viewName = '/'
21+
22+
for (const routeRecord of matched) {
23+
const path = routeRecord.path
24+
if (!path) {
25+
continue
26+
}
27+
28+
// Note: Vue Router normalizes all paths in the matched array to absolute paths,
29+
// so the relative-path branch below is purely defensive and not expected to be
30+
// hit in practice. It mirrors the React Router implementation for consistency.
31+
if (path.startsWith('/')) {
32+
viewName = path
33+
} else {
34+
if (!viewName.endsWith('/')) {
35+
viewName += '/'
36+
}
37+
viewName += path
38+
}
39+
}
40+
41+
return viewName
42+
}
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
import { createMemoryHistory } from 'vue-router'
2+
import { initializeVuePlugin } from '../../../test/initializeVuePlugin'
3+
import { createRouter } from './vueRouter'
4+
5+
describe('createRouter (wrapped)', () => {
6+
it('calls startView on navigation', (done) => {
7+
const startViewSpy = jasmine.createSpy()
8+
initializeVuePlugin({
9+
configuration: { router: true },
10+
publicApi: { startView: startViewSpy },
11+
})
12+
13+
const router = createRouter({
14+
history: createMemoryHistory(),
15+
routes: [
16+
{ path: '/', component: {} },
17+
{ path: '/about', component: {} },
18+
],
19+
})
20+
21+
router
22+
.push('/')
23+
.then(() => {
24+
expect(startViewSpy).toHaveBeenCalledWith('/')
25+
return router.push('/about')
26+
})
27+
.then(() => {
28+
expect(startViewSpy).toHaveBeenCalledWith('/about')
29+
done()
30+
})
31+
.catch(done.fail)
32+
})
33+
34+
it('does not call startView when navigation is blocked', (done) => {
35+
const startViewSpy = jasmine.createSpy()
36+
initializeVuePlugin({
37+
configuration: { router: true },
38+
publicApi: { startView: startViewSpy },
39+
})
40+
41+
const router = createRouter({
42+
history: createMemoryHistory(),
43+
routes: [
44+
{ path: '/', component: {} },
45+
{ path: '/protected', component: {} },
46+
],
47+
})
48+
49+
// Block all navigations to /protected
50+
router.beforeEach((to) => {
51+
if (to.path === '/protected') {
52+
return false
53+
}
54+
})
55+
56+
router
57+
.push('/')
58+
.then(() => {
59+
startViewSpy.calls.reset()
60+
return router.push('/protected')
61+
})
62+
.then(() => {
63+
expect(startViewSpy).not.toHaveBeenCalled()
64+
done()
65+
})
66+
.catch(done.fail)
67+
})
68+
})
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
import { createRouter as originalCreateRouter, isNavigationFailure, NavigationFailureType } from 'vue-router'
2+
import type { RouterOptions, Router } from 'vue-router'
3+
import { startVueRouterView } from './startVueRouterView'
4+
5+
export function createRouter(options: RouterOptions): Router {
6+
const router = originalCreateRouter(options)
7+
8+
// afterEach fires for the initial navigation when the app is mounted via app.use(router).
9+
// In tests without mounting, an explicit router.push() is needed to trigger the hook.
10+
router.afterEach((to, _from, failure) => {
11+
if (failure && !isNavigationFailure(failure, NavigationFailureType.duplicated)) {
12+
return
13+
}
14+
startVueRouterView(to.matched)
15+
})
16+
17+
return router
18+
}
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
export {}
1+
export { createRouter } from '../domain/router/vueRouter'

0 commit comments

Comments
 (0)