Skip to content

Commit c7735d3

Browse files
babu-chposva
andauthored
fix: trigger navigation guards when keep-alive component is reactivated for different route (#2604)
Fix #2601 Co-authored-by: Eduardo San Martin Morote <posva13@gmail.com>
1 parent 6fc56e3 commit c7735d3

File tree

3 files changed

+202
-30
lines changed

3 files changed

+202
-30
lines changed

packages/router/__tests__/guards/onBeforeRouteLeave.spec.ts

Lines changed: 82 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,15 +5,96 @@ import {
55
createRouter,
66
createMemoryHistory,
77
onBeforeRouteLeave,
8+
type RouteRecordRaw,
89
} from '../../src'
9-
import { createApp, defineComponent } from 'vue'
10+
import { createApp, defineComponent, onActivated, onDeactivated } from 'vue'
11+
import { mount } from '@vue/test-utils'
1012
import { vi, describe, expect, it } from 'vitest'
1113

1214
const component = {
1315
template: '<div>Generic</div>',
1416
}
1517

1618
describe('onBeforeRouteLeave', () => {
19+
it('triggers when shared KeepAlive component is reactivated for a different route', async () => {
20+
const routeLeaveSpy = vi.fn()
21+
const activatedSpy = vi.fn()
22+
const deactivatedSpy = vi.fn()
23+
const setupSpy = vi.fn(() => {
24+
onBeforeRouteLeave(routeLeaveSpy)
25+
onActivated(activatedSpy)
26+
onDeactivated(deactivatedSpy)
27+
return {}
28+
})
29+
30+
// A shared component used by multiple routes (simulates list pages)
31+
const SharedComponent = defineComponent({
32+
template: '<div>Shared: {{ $route.path }}</div>',
33+
setup: setupSpy,
34+
})
35+
36+
// A different component (simulates detail page)
37+
const DetailComponent = defineComponent({
38+
template: '<div>Detail</div>',
39+
})
40+
41+
const routes: RouteRecordRaw[] = [
42+
{ path: '/', component },
43+
{ path: '/a', component: SharedComponent },
44+
{ path: '/other', component: DetailComponent },
45+
{ path: '/b', component: SharedComponent },
46+
]
47+
48+
const router = createRouter({
49+
history: createMemoryHistory(),
50+
routes,
51+
})
52+
53+
const wrapper = mount(
54+
{
55+
template: `
56+
<router-view v-slot="{ Component }">
57+
<keep-alive>
58+
<component :is="Component" />
59+
</keep-alive>
60+
</router-view>
61+
`,
62+
},
63+
{
64+
global: {
65+
plugins: [router],
66+
},
67+
}
68+
)
69+
await router.isReady()
70+
71+
// Step 1: Navigate to /a - component mounts and registers guard with /a's record
72+
await router.push('/a')
73+
expect(routeLeaveSpy).not.toHaveBeenCalled()
74+
expect(setupSpy).toHaveBeenCalledTimes(1)
75+
expect(activatedSpy).toHaveBeenCalledTimes(1)
76+
77+
// Step 2: Navigate to another route so SharedComponent is deactivated (kept alive)
78+
// Leave guard is called when leaving /a
79+
await router.push('/other')
80+
expect(deactivatedSpy).toHaveBeenCalledTimes(1)
81+
expect(routeLeaveSpy).toHaveBeenCalledTimes(1) // called when leaving /a
82+
83+
// Step 3: Navigate to /b - SharedComponent is reactivated for a DIFFERENT route
84+
// The guard should be re-registered with /b's record
85+
await router.push('/b')
86+
expect(activatedSpy).toHaveBeenCalledTimes(2)
87+
expect(setupSpy).toHaveBeenCalledTimes(1) // still only mounted once (kept alive)
88+
89+
// Step 4: Leave /b - onBeforeRouteLeave SHOULD be triggered
90+
// BUG (before fix): The guard was registered with /a's record, not /b's record
91+
// So leaving /b would not trigger the guard
92+
await router.push('/')
93+
expect(routeLeaveSpy).toHaveBeenCalledTimes(2) // called again when leaving /b
94+
95+
wrapper.unmount()
96+
})
97+
1798
it('removes guards when leaving the route', async () => {
1899
const spy = vi.fn()
19100
const WithLeave = defineComponent({

packages/router/__tests__/guards/onBeforeRouteUpdate.spec.ts

Lines changed: 74 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,16 @@ import {
66
createMemoryHistory,
77
onBeforeRouteUpdate,
88
RouterView,
9-
RouteRecordRaw,
9+
type RouteRecordRaw,
1010
} from '../../src'
11-
import { defineComponent, h, ComponentOptions, FunctionalComponent } from 'vue'
11+
import {
12+
defineComponent,
13+
h,
14+
ComponentOptions,
15+
FunctionalComponent,
16+
onActivated,
17+
onDeactivated,
18+
} from 'vue'
1219
import { mount } from '@vue/test-utils'
1320
import { delay } from '../utils'
1421
import { vi, describe, expect, it } from 'vitest'
@@ -53,6 +60,71 @@ function factory(
5360
return { wrapper, router }
5461
}
5562
describe('onBeforeRouteUpdate', () => {
63+
it('triggers when shared KeepAlive component is reactivated for a different route', async () => {
64+
const routeUpdateSpy = vi.fn()
65+
const activatedSpy = vi.fn()
66+
const deactivatedSpy = vi.fn()
67+
const setupSpy = vi.fn(() => {
68+
onBeforeRouteUpdate(routeUpdateSpy)
69+
onActivated(activatedSpy)
70+
onDeactivated(deactivatedSpy)
71+
return {}
72+
})
73+
74+
// A shared component used by multiple routes (simulates list pages)
75+
const SharedComponent = defineComponent({
76+
template: '<div>Shared: {{ $route.path }}</div>',
77+
setup: setupSpy,
78+
})
79+
80+
// A different component (simulates detail page)
81+
const DetailComponent = defineComponent({
82+
template: '<div>Detail</div>',
83+
})
84+
85+
const { router, wrapper } = factory(
86+
[
87+
{ path: '/', component },
88+
{ path: '/a', component: SharedComponent },
89+
{ path: '/b', component: SharedComponent },
90+
],
91+
{
92+
template: `
93+
<router-view v-slot="{ Component }">
94+
<keep-alive>
95+
<component :is="Component" />
96+
</keep-alive>
97+
</router-view>
98+
`,
99+
}
100+
)
101+
await router.isReady()
102+
103+
// Step 1: Navigate to /a - component mounts and registers guard with /a's record
104+
await router.push('/a')
105+
expect(routeUpdateSpy).not.toHaveBeenCalled()
106+
expect(setupSpy).toHaveBeenCalledTimes(1)
107+
expect(activatedSpy).toHaveBeenCalledTimes(1) // activated on mount
108+
109+
// Step 2: Navigate somewhere else - SharedComponent is deactivated
110+
await router.push('/')
111+
expect(deactivatedSpy).toHaveBeenCalledTimes(1)
112+
113+
// Step 3: Navigate to /b - SharedComponent is reactivated for a DIFFERENT route
114+
// This is where the bug occurs: guard is added back to /a's record, not /b's
115+
await router.push('/b')
116+
expect(activatedSpy).toHaveBeenCalledTimes(2) // reactivated
117+
expect(setupSpy).toHaveBeenCalledTimes(1) // still only mounted once (kept alive)
118+
119+
// Step 4: Update query on /b - onBeforeRouteUpdate SHOULD be triggered
120+
// BUG (before fix): The guard was registered with /a's record, not /b's record
121+
// So when /b updates, the guard is not called
122+
await router.push('/b?page=2')
123+
expect(routeUpdateSpy).toHaveBeenCalledTimes(1)
124+
125+
wrapper.unmount()
126+
})
127+
56128
it('removes update guards when leaving', async () => {
57129
const { spy: routeUpdate, Component } = withSpy()
58130

packages/router/src/navigationGuards.ts

Lines changed: 46 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,13 @@ import {
1616
NavigationFailure,
1717
NavigationRedirectError,
1818
} from './errors'
19-
import { ComponentOptions, onUnmounted, onActivated, onDeactivated } from 'vue'
19+
import {
20+
ComponentOptions,
21+
onUnmounted,
22+
onActivated,
23+
onDeactivated,
24+
ComputedRef,
25+
} from 'vue'
2026
import { inject, getCurrentInstance } from 'vue'
2127
import { matchedRouteKey } from './injectionSymbols'
2228
import { RouteRecordNormalized } from './matcher/types'
@@ -25,22 +31,51 @@ import { warn } from './warning'
2531
import { isSameRouteRecord } from './location'
2632

2733
function registerGuard(
28-
record: RouteRecordNormalized,
34+
activeRecordRef: ComputedRef<RouteRecordNormalized | undefined>,
2935
name: 'leaveGuards' | 'updateGuards',
3036
guard: NavigationGuard
3137
) {
38+
const record = activeRecordRef.value
39+
if (!record) {
40+
if (__DEV__) {
41+
const fnName =
42+
name === 'updateGuards' ? 'onBeforeRouteUpdate' : 'onBeforeRouteLeave'
43+
warn(
44+
`No active route record was found when calling \`${fnName}()\`. ` +
45+
`Make sure you call this function inside a component child of <router-view>. ` +
46+
`Maybe you called it inside of App.vue?`
47+
)
48+
}
49+
return
50+
}
51+
52+
// Track the current record the guard is registered with
53+
let currentRecord = record
54+
3255
const removeFromList = () => {
33-
record[name].delete(guard)
56+
currentRecord[name].delete(guard)
3457
}
3558

3659
onUnmounted(removeFromList)
3760
onDeactivated(removeFromList)
3861

3962
onActivated(() => {
40-
record[name].add(guard)
63+
// When reactivated, check if the active record has changed (e.g., keep-alive
64+
// component reactivated for a different route). If so, register with the new record.
65+
const newRecord = activeRecordRef.value
66+
if (__DEV__ && !newRecord) {
67+
warn(
68+
'No active route record was found when reactivating component with navigation guard. ' +
69+
'This is likely a bug in vue-router. Please report it.'
70+
)
71+
}
72+
if (newRecord) {
73+
currentRecord = newRecord
74+
}
75+
currentRecord[name].add(guard)
4176
})
4277

43-
record[name].add(guard)
78+
currentRecord[name].add(guard)
4479
}
4580

4681
/**
@@ -58,21 +93,13 @@ export function onBeforeRouteLeave(leaveGuard: NavigationGuard) {
5893
return
5994
}
6095

61-
const activeRecord: RouteRecordNormalized | undefined = inject(
96+
const activeRecordRef = inject(
6297
matchedRouteKey,
6398
// to avoid warning
6499
{} as any
65-
).value
66-
67-
if (!activeRecord) {
68-
__DEV__ &&
69-
warn(
70-
'No active route record was found when calling `onBeforeRouteLeave()`. Make sure you call this function inside a component child of <router-view>. Maybe you called it inside of App.vue?'
71-
)
72-
return
73-
}
100+
) as ComputedRef<RouteRecordNormalized | undefined>
74101

75-
registerGuard(activeRecord, 'leaveGuards', leaveGuard)
102+
registerGuard(activeRecordRef, 'leaveGuards', leaveGuard)
76103
}
77104

78105
/**
@@ -90,21 +117,13 @@ export function onBeforeRouteUpdate(updateGuard: NavigationGuard) {
90117
return
91118
}
92119

93-
const activeRecord: RouteRecordNormalized | undefined = inject(
120+
const activeRecordRef = inject(
94121
matchedRouteKey,
95122
// to avoid warning
96123
{} as any
97-
).value
98-
99-
if (!activeRecord) {
100-
__DEV__ &&
101-
warn(
102-
'No active route record was found when calling `onBeforeRouteUpdate()`. Make sure you call this function inside a component child of <router-view>. Maybe you called it inside of App.vue?'
103-
)
104-
return
105-
}
124+
) as ComputedRef<RouteRecordNormalized | undefined>
106125

107-
registerGuard(activeRecord, 'updateGuards', updateGuard)
126+
registerGuard(activeRecordRef, 'updateGuards', updateGuard)
108127
}
109128

110129
export function guardToPromiseFn(

0 commit comments

Comments
 (0)