diff --git a/packages/vue-router/src/router.ts b/packages/vue-router/src/router.ts index 02d6ae8216b..30998b56601 100644 --- a/packages/vue-router/src/router.ts +++ b/packages/vue-router/src/router.ts @@ -29,6 +29,7 @@ export const createIonRouter = ( direction: undefined, action: undefined, delta: undefined, + to: undefined, }; /** @@ -46,7 +47,11 @@ export const createIonRouter = ( _: RouteLocationNormalized, failure?: NavigationFailure | void ) => { - if (failure) return; + if (failure) { + discardStagedStateFor(to); + + return; + } const { direction, action, delta } = currentNavigationInfo; @@ -68,10 +73,26 @@ export const createIonRouter = ( direction: undefined, action: undefined, delta: undefined, + to: undefined, }; } ); + /** + * A guard that throws, including an await on a session check that rejects, + * never reaches afterEach. vue-router rejects the navigation promise + * instead, so there is no failure to inspect there and the staged state + * would survive. This does not handle the error, so navigation outcomes are + * unchanged. + * + * A guard that returns a location is still not covered, because that + * redirects rather than fails and afterEach is never called for the original + * navigation. + */ + router.onError((_error: unknown, to: RouteLocationNormalized) => { + discardStagedStateFor(to); + }); + const locationHistory = createLocationHistory(); /** @@ -89,6 +110,99 @@ export const createIonRouter = ( * Cleared once `handleHistoryChange` has consumed them. */ let incomingRouteParams: RouteParams | undefined; + /** + * The location the staged params were meant for. Kept beside the params + * rather than on them so it is never spread onto a RouteInfo. Left undefined + * by the helpers that hand off to history and so cannot know the target yet, + * which is `goBack`, `goForward` and `handleNavigateBack`. Those fall back to + * the delta's target, which history always records for them. + */ + let incomingRouteParamsTo: string | undefined; + + /** + * `resolve` encodes a query differently depending on whether it was handed a + * string or an object, so a space in a value survives the string form and + * becomes a plus in the object form. The location `afterEach` reports always + * uses the object form, so resolve a second time to normalize it. + */ + const resolveFullPath = (to: RouteLocationRaw) => { + const resolved = router.resolve(to); + + return router.resolve({ + path: resolved.path, + query: resolved.query, + hash: resolved.hash, + }).fullPath; + }; + + /** + * The only place that stages route params, so the recorded location can + * never be left over from an earlier navigation. Pass the target when it is + * known, and omit it to fall back to the delta. + */ + const stageRouteParams = (params: RouteParams, to?: RouteLocationRaw) => { + incomingRouteParams = params; + incomingRouteParamsTo = to ? resolveFullPath(to) : undefined; + }; + + /** + * The only place that clears them, so a target can never outlive the params + * it was recorded for and go on to match an unrelated navigation. + */ + const clearStagedParams = () => { + incomingRouteParams = undefined; + incomingRouteParamsTo = undefined; + }; + + /** + * State staged for a navigation that did not complete describes something + * that did not happen. handleHistoryChange normally consumes it, but it does + * not run for a navigation that failed, so it has to be discarded here or + * the next navigation picks it up instead. + * + * A delta is only staged for a history navigation, and a stale one makes the + * next navigation look like traversal, which stops the incoming route from + * being added. A stale set of params carries an action, a direction and + * sometimes a tab or a previous route's id into whatever runs next. + * + * Only discard state belonging to this navigation, and check the two slots + * separately. Another navigation can replace this one and stage its own + * state first, in which case discarding would strip that state from the + * navigation still running. Params staged without a target fall back to the + * delta's target, which history always records for the helpers that omit + * one. + * + * The params were staged from what the caller asked for, so a `redirect:` + * record leaves them recorded against the location before the redirect while + * `afterEach` reports the one after it. `redirectedFrom` is what the two have + * in common. The delta needs no such allowance, since history records the + * location the browser actually moved to, which is already the redirected + * one. + */ + const discardStagedStateFor = (to: RouteLocationNormalized) => { + const deltaIsForThisNavigation = + currentNavigationInfo.to === undefined || + currentNavigationInfo.to === to.fullPath; + + const paramsAreForThisNavigation = + incomingRouteParamsTo === undefined + ? deltaIsForThisNavigation + : incomingRouteParamsTo === to.fullPath || + incomingRouteParamsTo === to.redirectedFrom?.fullPath; + + if (deltaIsForThisNavigation) { + currentNavigationInfo = { + direction: undefined, + action: undefined, + delta: undefined, + to: undefined, + }; + } + + if (paramsAreForThisNavigation) { + clearStagedParams(); + } + }; const historyChangeListeners: any[] = []; @@ -101,7 +215,7 @@ export const createIonRouter = ( }); } - opts.history.listen((_: any, _x: any, info: any) => { + opts.history.listen((to: any, _x: any, info: any) => { /** * history.listen only fires on certain * event such as when the user clicks the @@ -123,6 +237,12 @@ export const createIonRouter = ( */ action: info.type === "pop" && info.delta >= 1 ? "push" : info.type, direction: info.direction === "" ? "forward" : info.direction, + + /** + * Recorded so that a failed navigation can tell whether this + * information is its own before clearing it. + */ + to, }; }); @@ -142,12 +262,12 @@ export const createIonRouter = ( if (routeInfo && routeInfo.pushedByRoute) { const prevInfo = locationHistory.findLastLocation(routeInfo); if (prevInfo) { - incomingRouteParams = { + stageRouteParams({ ...prevInfo, routerAction: "pop", routerDirection: "back", routerAnimation: routerAnimation || routeInfo.routerAnimation, - }; + }); if ( routeInfo.lastPathname === routeInfo.pushedByRoute || /** @@ -222,7 +342,7 @@ export const createIonRouter = ( * There is nowhere to navigate, so drop the params rather than * letting them leak into the next navigation. */ - incomingRouteParams = undefined; + clearStagedParams(); } } } else if (defaultHref) { @@ -240,7 +360,13 @@ export const createIonRouter = ( routerAnimation?: AnimationBuilder, tab?: string ) => { - setIncomingRouteParams(routerAction, routerDirection, routerAnimation, tab); + setIncomingRouteParams( + routerAction, + routerDirection, + routerAnimation, + tab, + path + ); if (routerAction === "push") { router.push(path); @@ -585,7 +711,7 @@ export const createIonRouter = ( currentRouteInfo = routeInfo; } - incomingRouteParams = undefined; + clearStagedParams(); historyChangeListeners.forEach((cb) => cb(currentRouteInfo)); }; @@ -601,7 +727,13 @@ export const createIonRouter = ( const navigate = (navigationOptions: ExternalNavigationOptions) => { const { routerAnimation, routerDirection, routerLink } = navigationOptions; - setIncomingRouteParams("push", routerDirection, routerAnimation); + setIncomingRouteParams( + "push", + routerDirection, + routerAnimation, + undefined, + routerLink + ); router.push(routerLink); }; @@ -665,13 +797,6 @@ export const createIonRouter = ( const hrefSearch = search ? `?${search}` : ""; if (routeInfo) { - incomingRouteParams = { - ...incomingRouteParams, - routerAction: "push", - routerDirection: "none", - tab, - }; - /** * When going back to a tab * you just left, it's possible @@ -684,15 +809,23 @@ export const createIonRouter = ( * are honored when re-selecting the tab. */ const effectiveSearch = hrefSearch || routeInfo.search || ""; - const push = { + const target = { + path: routeInfo.pathname === pathname ? routeInfo.pathname : pathname, query: parseQuery(effectiveSearch), ...(hrefHash ? { hash: hrefHash } : {}), }; - if (routeInfo.pathname === pathname) { - router.push({ path: routeInfo.pathname, ...push }); - } else { - router.push({ path: pathname, ...push }); - } + + stageRouteParams( + { + ...incomingRouteParams, + routerAction: "push", + routerDirection: "none", + tab, + }, + target + ); + + router.push(target); } else { handleNavigate( pathname + hrefSearch + hrefHash, @@ -790,14 +923,18 @@ export const createIonRouter = ( routerAction: RouteAction = "push", routerDirection: RouteDirection = "forward", routerAnimation?: AnimationBuilder, - tab?: string + tab?: string, + to?: RouteLocationRaw ) => { - incomingRouteParams = { - routerAction, - routerDirection, - routerAnimation, - tab, - }; + stageRouteParams( + { + routerAction, + routerDirection, + routerAnimation, + tab, + }, + to + ); }; const goBack = (routerAnimation?: AnimationBuilder) => { diff --git a/packages/vue-router/src/types.ts b/packages/vue-router/src/types.ts index e1cddbfde58..7f502d548e4 100644 --- a/packages/vue-router/src/types.ts +++ b/packages/vue-router/src/types.ts @@ -91,4 +91,10 @@ export interface NavigationInformation { action?: RouteAction; direction?: RouteDirection; delta?: number; + /** + * The location the browser moved to when this information was staged. Used to + * tell whether the information belongs to a particular navigation, since a + * second history navigation can stage its own before the first one settles. + */ + to?: string; } diff --git a/packages/vue/test/base/tests/unit/routing.spec.ts b/packages/vue/test/base/tests/unit/routing.spec.ts index d03d00ee250..2209667cd48 100644 --- a/packages/vue/test/base/tests/unit/routing.spec.ts +++ b/packages/vue/test/base/tests/unit/routing.spec.ts @@ -1,4 +1,4 @@ -import { enableAutoUnmount, mount } from '@vue/test-utils'; +import { enableAutoUnmount, flushPromises, mount } from '@vue/test-utils'; import { afterEach, describe, expect, it, vi } from 'vitest'; import { createRouter, createWebHistory } from '@ionic/vue-router'; import { @@ -12,7 +12,12 @@ import { IonLabel, useIonRouter } from '@ionic/vue'; -import { onBeforeRouteLeave } from 'vue-router'; +import { + isNavigationFailure, + NavigationFailureType, + onBeforeRouteLeave +} from 'vue-router'; +import { inject } from 'vue'; import { waitForRouter } from './utils'; enableAutoUnmount(afterEach); @@ -22,6 +27,39 @@ const BasePage = { components: { IonPage }, } +/* + * Kept separate from BasePage because BasePage binds `:data-pageid="name"`, and + * a component's name option is not reachable from its template in Vue 3, so + * that attribute renders empty. + */ +const createPage = (id: string) => ({ + components: { IonPage }, + name: id, + template: `` +}); + +/* + * Ionic keeps previously visited pages mounted so they can be animated back to, + * hiding the inactive ones with `ion-page-hidden`. Asserting on the whole stack + * catches both a wrong visible page and a page that was destroyed when it + * should have been kept. + */ +const viewStack = (wrapper: any) => + wrapper.findAll('.ion-page').map((page: any) => ({ + id: page.attributes('data-pageid'), + hidden: page.classes('ion-page-hidden') + })); + +const currentRoute = (navManager: any) => { + const routeInfo = navManager.getCurrentRouteInfo(); + + return { + pathname: routeInfo.pathname, + routerAction: routeInfo.routerAction, + routerDirection: routeInfo.routerDirection + }; +}; + describe('Routing', () => { it('should pass no props', async () => { const Page1 = { @@ -733,4 +771,927 @@ describe('Routing', () => { expect(wrapper.findComponent(Page2).exists()).toBe(false); expect(wrapper.findComponent(Page3).exists()).toBe(false); }); + + // Verifies fix for https://github.com/ionic-team/ionic-framework/issues/29721 + it('should keep the previous page when pushing after a guard blocks going back', async () => { + /* + * The pages are rendered inside the outlet, so injecting from one of them + * reaches the router the same way useIonRouter does, without wrapping the + * outlet in another component. + */ + let navManager: any; + const Home = { + ...createPage('home'), + setup() { + navManager = inject('navManager'); + } + }; + const Register = createPage('register'); + const Profile = createPage('profile'); + + let isLoggedIn = false; + + const router = createRouter({ + history: createWebHistory(process.env.BASE_URL), + routes: [ + { path: '/', redirect: '/home' }, + { path: '/home', component: Home }, + { path: '/register', component: Register }, + { path: '/profile', component: Profile } + ] + }); + + /* + * Leaving the authenticated route while still logged in is blocked, which + * aborts the navigation. An aborted back navigation used to leave stale + * navigation info behind, which then made the next navigation look like + * history traversal. + */ + router.beforeEach((to, from) => { + if (from.path === '/profile' && to.path !== '/profile' && isLoggedIn) { + return false; + } + + return true; + }); + + router.push('/'); + await router.isReady(); + const wrapper = mount(IonRouterOutlet, { + global: { + plugins: [router, IonicVue] + } + }); + + router.push('/register'); + await waitForRouter(); + + isLoggedIn = true; + router.replace('/profile'); + await waitForRouter(); + + expect(viewStack(wrapper)).toEqual([ + { id: 'home', hidden: true }, + { id: 'profile', hidden: false } + ]); + + // The guard blocks this, so the stack should be untouched. + router.back(); + await waitForRouter(); + + expect(viewStack(wrapper)).toEqual([ + { id: 'home', hidden: true }, + { id: 'profile', hidden: false } + ]); + + /* + * Logging out is a push, so Profile stays in the stack behind Home and the + * route is recorded as a forward push. The stale delta from the blocked + * back navigation used to make this look like history traversal, which + * recorded it as a pop going back and destroyed the Profile view. + */ + isLoggedIn = false; + router.push('/home'); + await waitForRouter(); + + expect(viewStack(wrapper)).toEqual([ + { id: 'home', hidden: false }, + { id: 'profile', hidden: true } + ]); + expect(currentRoute(navManager)).toEqual({ + pathname: '/home', + routerAction: 'push', + routerDirection: 'forward' + }); + + router.push('/profile'); + await waitForRouter(); + + expect(viewStack(wrapper)).toEqual([ + { id: 'home', hidden: true }, + { id: 'profile', hidden: false } + ]); + }); + + // Verifies fix for https://github.com/ionic-team/ionic-framework/issues/29721 + it('should keep canGoBack accurate after a guard blocks a programmatic back', async () => { + const Home = createPage('home'); + const Profile = createPage('profile'); + const Settings = createPage('settings'); + + const AppWithInject = { + components: { IonApp, IonRouterOutlet }, + name: 'AppWithInject', + template: '', + setup() { + const ionRouter = useIonRouter(); + return { ionRouter }; + } + }; + + let isLoggedIn = false; + + const router = createRouter({ + history: createWebHistory(process.env.BASE_URL), + routes: [ + { path: '/', redirect: '/home' }, + { path: '/home', component: Home }, + { path: '/profile', component: Profile }, + { path: '/settings', component: Settings } + ] + }); + + router.beforeEach((to, from) => { + if (from.path === '/profile' && to.path !== '/profile' && isLoggedIn) { + return false; + } + + return true; + }); + + router.push('/'); + await router.isReady(); + const wrapper = mount(AppWithInject, { + global: { + plugins: [router, IonicVue] + } + }); + + const ionRouter = wrapper.vm.ionRouter; + + router.push('/profile'); + await waitForRouter(); + + expect(ionRouter.canGoBack()).toEqual(true); + + /* + * useIonRouter's back() stages route params before handing off to the + * router. The guard blocks the navigation, so those params used to be left + * behind and then applied to the next route instead. + */ + isLoggedIn = true; + ionRouter.back(); + await waitForRouter(); + + /* + * Navigating with vue-router rather than useIonRouter matters here. The + * useIonRouter helpers stage their own route params, which would overwrite + * the leftovers and hide the problem. + */ + isLoggedIn = false; + router.push('/settings'); + await waitForRouter(); + + expect(ionRouter.canGoBack()).toEqual(true); + }); + + // Verifies fix for https://github.com/ionic-team/ionic-framework/issues/29721 + it('should not apply a cancelled back navigation to the navigation that replaced it', async () => { + let navManager: any; + const Home = { + ...createPage('home'), + setup() { + navManager = inject('navManager'); + } + }; + const Profile = createPage('profile'); + const Settings = createPage('settings'); + + let racing = false; + let releaseBack!: () => void; + let releasePush!: () => void; + let backReachedGuard: () => void; + let cancelReported: () => void; + + const backStarted = new Promise((resolve) => { + backReachedGuard = resolve as () => void; + }); + const backCancelled = new Promise((resolve) => { + cancelReported = resolve as () => void; + }); + + const router = createRouter({ + history: createWebHistory(process.env.BASE_URL), + routes: [ + { path: '/home', component: Home }, + { path: '/profile', component: Profile }, + { path: '/settings', component: Settings } + ] + }); + + /* + * Both navigations are held inside their guards so the test controls the + * order they finish in, rather than relying on timing. The back navigation + * is released first so it reports its cancellation before the push that + * replaced it completes. + */ + router.beforeEach(async (to) => { + if (!racing) { + return true; + } + + if (to.path === '/home') { + backReachedGuard(); + await new Promise((resolve) => { + releaseBack = resolve as () => void; + }); + } + + if (to.path === '/settings') { + await new Promise((resolve) => { + releasePush = resolve as () => void; + }); + } + + return true; + }); + + router.afterEach((_to, _from, failure) => { + if (isNavigationFailure(failure, NavigationFailureType.cancelled)) { + cancelReported(); + } + }); + + router.push('/home'); + await router.isReady(); + const wrapper = mount(IonRouterOutlet, { + global: { + plugins: [router, IonicVue] + } + }); + + router.push('/profile'); + await waitForRouter(); + + racing = true; + + // Start going back, and wait until it is actually in flight. + router.back(); + await backStarted; + + // Replace it with a push while it is still in flight. + router.push('/settings'); + await flushPromises(); + + // Let the back navigation finish, which reports it as cancelled. + releaseBack(); + await backCancelled; + + // Only now let the push finish, so it is the one reading any staged state. + releasePush(); + await waitForRouter(); + + expect(viewStack(wrapper)).toEqual([ + { id: 'home', hidden: true }, + { id: 'profile', hidden: true }, + { id: 'settings', hidden: false } + ]); + expect(currentRoute(navManager)).toEqual({ + pathname: '/settings', + routerAction: 'push', + routerDirection: 'forward' + }); + }); + + // Verifies fix for https://github.com/ionic-team/ionic-framework/issues/29721 + it('should not reuse the previous route after a guard blocks a back button navigation', async () => { + let navManager: any; + const Home = { + ...createPage('home'), + setup() { + navManager = inject('navManager'); + } + }; + const Profile = createPage('profile'); + const Settings = createPage('settings'); + + let isLoggedIn = false; + + const router = createRouter({ + history: createWebHistory(process.env.BASE_URL), + routes: [ + { path: '/', redirect: '/home' }, + { path: '/home', component: Home }, + { path: '/profile', component: Profile }, + { path: '/settings', component: Settings } + ] + }); + + router.beforeEach((to, from) => { + if (from.path === '/profile' && to.path !== '/profile' && isLoggedIn) { + return false; + } + + return true; + }); + + router.push('/'); + await router.isReady(); + const wrapper = mount(IonRouterOutlet, { + global: { + plugins: [router, IonicVue] + } + }); + + router.push('/profile'); + await waitForRouter(); + + /* + * ion-back-button calls handleNavigateBack, which stages the whole previous + * route rather than just an action and direction. Those params carry an id, + * and a staged id makes handleHistoryChange reuse the params wholesale, so + * a stale set would report the previous route's pathname for whatever is + * navigated to next. + */ + isLoggedIn = true; + navManager.handleNavigateBack(); + await waitForRouter(); + + isLoggedIn = false; + router.push('/settings'); + await waitForRouter(); + + expect(currentRoute(navManager)).toEqual({ + pathname: '/settings', + routerAction: 'push', + routerDirection: 'forward' + }); + }); + + // Guards against clearing params that belong to another navigation still in flight. + it('should keep the route params of a navigation that replaced a cancelled one', async () => { + let navManager: any; + + const Home = { + ...createPage('home'), + setup() { + navManager = inject('navManager'); + } + }; + const Slow = createPage('slow'); + const Login = createPage('login'); + + let racing = false; + let releaseSlow!: () => void; + let releaseLogin!: () => void; + let slowReachedGuard: () => void; + let loginReachedGuard: () => void; + let cancelReported: () => void; + + const slowStarted = new Promise((resolve) => { + slowReachedGuard = resolve as () => void; + }); + const loginStarted = new Promise((resolve) => { + loginReachedGuard = resolve as () => void; + }); + const pushCancelled = new Promise((resolve) => { + cancelReported = resolve as () => void; + }); + + const router = createRouter({ + history: createWebHistory(process.env.BASE_URL), + routes: [ + { path: '/', redirect: '/home' }, + { path: '/home', component: Home }, + { path: '/slow', component: Slow }, + { path: '/login', component: Login } + ] + }); + + /* + * Neither navigation here is a history traversal, so no delta is ever + * staged. The staged params are the only state in play, which is why they + * need a target of their own to be told apart. + */ + router.beforeEach(async (to) => { + if (!racing) { + return true; + } + + if (to.path === '/slow') { + slowReachedGuard(); + await new Promise((resolve) => { + releaseSlow = resolve as () => void; + }); + } + + if (to.path === '/login') { + loginReachedGuard(); + await new Promise((resolve) => { + releaseLogin = resolve as () => void; + }); + } + + return true; + }); + + router.afterEach((_to, _from, failure) => { + if (isNavigationFailure(failure, NavigationFailureType.cancelled)) { + cancelReported(); + } + }); + + router.push('/'); + await router.isReady(); + const wrapper = mount(IonRouterOutlet, { + global: { + plugins: [router, IonicVue] + } + }); + + racing = true; + + /* + * These are what useIonRouter's push and replace call through to, used + * directly so the outlet can stay the mounted component. + */ + navManager.handleNavigate('/slow', 'push', 'forward'); + await slowStarted; + + // Logging out replaces it, staging its own params on the way. + navManager.handleNavigate('/login', 'replace', 'root'); + await loginStarted; + + // Let the push finish, which reports it as cancelled. + releaseSlow(); + await pushCancelled; + + // Only now let the replace finish, so it is the one reading staged params. + releaseLogin(); + await waitForRouter(); + + expect(currentRoute(navManager)).toEqual({ + pathname: '/login', + routerAction: 'replace', + routerDirection: 'root' + }); + + /* + * A root replace clears the history, so Login is the only page left. Losing + * the staged params drops the root direction, and the pages behind it stay. + */ + expect(viewStack(wrapper)).toEqual([{ id: 'login', hidden: false }]); + }); + + // Verifies fix for https://github.com/ionic-team/ionic-framework/issues/29721 + it('should not apply the params of a blocked replace to the next navigation', async () => { + let navManager: any; + + const Home = { + ...createPage('home'), + setup() { + navManager = inject('navManager'); + } + }; + const Blocked = createPage('blocked'); + const Other = createPage('other'); + + let blocking = false; + + const router = createRouter({ + history: createWebHistory(process.env.BASE_URL), + routes: [ + { path: '/', redirect: '/home' }, + { path: '/home', component: Home }, + { path: '/blocked', component: Blocked }, + { path: '/other', component: Other } + ] + }); + + router.beforeEach((to) => { + if (blocking && to.path === '/blocked') { + return false; + } + + return true; + }); + + router.push('/'); + await router.isReady(); + const wrapper = mount(IonRouterOutlet, { + global: { + plugins: [router, IonicVue] + } + }); + + /* + * A root replace stages its own params and records where they were meant + * for, then the guard blocks it. Leaving those behind hands a root replace + * to the next navigation, which clears the history it should have kept. + */ + blocking = true; + navManager.handleNavigate('/blocked', 'replace', 'root'); + await waitForRouter(); + + router.push('/other'); + await waitForRouter(); + + expect(currentRoute(navManager)).toEqual({ + pathname: '/other', + routerAction: 'push', + routerDirection: 'forward' + }); + + // A leaked root replace clears the history, which would drop Home. + expect(viewStack(wrapper)).toEqual([ + { id: 'home', hidden: true }, + { id: 'other', hidden: false } + ]); + }); + + // Verifies fix for https://github.com/ionic-team/ionic-framework/issues/29721 + it('should discard the staged params when the blocked route redirects', async () => { + let navManager: any; + + const Home = { + ...createPage('home'), + setup() { + navManager = inject('navManager'); + } + }; + const Tab = createPage('tab'); + const Other = createPage('other'); + + let blocking = false; + + const router = createRouter({ + history: createWebHistory(process.env.BASE_URL), + routes: [ + { path: '/', redirect: '/home' }, + { path: '/home', component: Home }, + { path: '/tabs', redirect: '/tabs/tab' }, + { path: '/tabs/tab', component: Tab }, + { path: '/other', component: Other } + ] + }); + + router.beforeEach((to) => { + if (blocking && to.path === '/tabs/tab') { + return false; + } + + return true; + }); + + router.push('/'); + await router.isReady(); + const wrapper = mount(IonRouterOutlet, { + global: { + plugins: [router, IonicVue] + } + }); + + /* + * The params are staged against the location the caller asked for, so a + * `redirect:` record leaves them recorded against `/tabs` while afterEach + * reports `/tabs/tab`. Without matching on the location before the + * redirect the two never line up, so the root replace survives and clears + * the history the next navigation should have kept. + */ + blocking = true; + navManager.handleNavigate('/tabs', 'replace', 'root'); + await waitForRouter(); + + router.push('/other'); + await waitForRouter(); + + expect(currentRoute(navManager)).toEqual({ + pathname: '/other', + routerAction: 'push', + routerDirection: 'forward' + }); + + expect(viewStack(wrapper)).toEqual([ + { id: 'home', hidden: true }, + { id: 'other', hidden: false } + ]); + }); + + // Verifies fix for https://github.com/ionic-team/ionic-framework/issues/29721 + it('should discard the staged params when the blocked href has a spaced query', async () => { + let navManager: any; + + const Home = { + ...createPage('home'), + setup() { + navManager = inject('navManager'); + } + }; + const Search = createPage('search'); + const Other = createPage('other'); + + let blocking = false; + + const router = createRouter({ + history: createWebHistory(process.env.BASE_URL), + routes: [ + { path: '/', redirect: '/home' }, + { path: '/home', component: Home }, + { path: '/search', component: Search }, + { path: '/other', component: Other } + ] + }); + + router.beforeEach((to) => { + if (blocking && to.path === '/search') { + return false; + } + + return true; + }); + + router.push('/'); + await router.isReady(); + const wrapper = mount(IonRouterOutlet, { + global: { + plugins: [router, IonicVue] + } + }); + + /* + * A back button falling through to its default href hands the href along + * as a string, and resolving a string leaves the space in the query alone + * while afterEach reports it as a plus. Without normalizing the two the + * pop/back params survive the block and turn the next push into a + * backwards navigation. + */ + blocking = true; + navManager.handleNavigate('/search?q=hello world', 'pop', 'back'); + await waitForRouter(); + + router.push('/other'); + await waitForRouter(); + + expect(currentRoute(navManager)).toEqual({ + pathname: '/other', + routerAction: 'push', + routerDirection: 'forward' + }); + + expect(viewStack(wrapper)).toEqual([ + { id: 'home', hidden: true }, + { id: 'other', hidden: false } + ]); + }); + + // Verifies fix for https://github.com/ionic-team/ionic-framework/issues/29721 + it('should show the correct view after a guard rejects instead of returning false', async () => { + let navManager: any; + + const Home = { + ...createPage('home'), + setup() { + navManager = inject('navManager'); + } + }; + const Profile = createPage('profile'); + const Settings = createPage('settings'); + + let sessionValid = true; + + const router = createRouter({ + history: createWebHistory(process.env.BASE_URL), + routes: [ + { path: '/', redirect: '/home' }, + { path: '/home', component: Home }, + { path: '/profile', component: Profile }, + { path: '/settings', component: Settings } + ] + }); + + /* + * A session check that rejects rather than returning false. vue-router + * rejects the navigation promise for this, so afterEach never runs and the + * staged state has to be discarded through onError instead. + */ + router.beforeEach(async (to, from) => { + if (from.path === '/profile' && to.path !== '/profile' && !sessionValid) { + await Promise.reject(new Error('session check failed')); + } + + return true; + }); + + // Stands in for an app that handles its own navigation errors. + router.onError(() => {}); + + router.push('/'); + await router.isReady(); + const wrapper = mount(IonRouterOutlet, { + global: { + plugins: [router, IonicVue] + } + }); + + router.push('/profile'); + await waitForRouter(); + + // Going back rejects inside the guard. + sessionValid = false; + router.back(); + await waitForRouter(); + + sessionValid = true; + router.push('/settings'); + await waitForRouter(); + + expect(currentRoute(navManager)).toEqual({ + pathname: '/settings', + routerAction: 'push', + routerDirection: 'forward' + }); + expect(viewStack(wrapper)).toEqual([ + { id: 'home', hidden: true }, + { id: 'profile', hidden: true }, + { id: 'settings', hidden: false } + ]); + }); + + // Guards against clearing params that belong to another navigation still in flight. + it('should keep the tab params of a tab change that replaced a cancelled push', async () => { + let navManager: any; + + const TabOne = { + ...createPage('tabone'), + setup() { + navManager = inject('navManager'); + } + }; + const TabTwo = createPage('tabtwo'); + const Details = createPage('details'); + + let guardDelay = 0; + + const router = createRouter({ + history: createWebHistory(process.env.BASE_URL), + routes: [ + { path: '/', redirect: '/tabs/tab1' }, + { path: '/tabs/tab1', component: TabOne }, + { path: '/tabs/tab2', component: TabTwo }, + { path: '/details', component: Details } + ] + }); + + // A global async guard, the session check kind. + router.beforeEach(async () => { + if (guardDelay) { + await new Promise((resolve) => setTimeout(resolve, guardDelay)); + } + + return true; + }); + + router.push('/'); + await router.isReady(); + mount(IonRouterOutlet, { + global: { + plugins: [router, IonicVue] + } + }); + + /* + * Both tabs need a routeInfo before changeTab will stage params itself + * rather than delegating to handleNavigate. + */ + navManager.changeTab('tab1', '/tabs/tab1'); + await waitForRouter(); + navManager.changeTab('tab2', '/tabs/tab2'); + await waitForRouter(); + navManager.changeTab('tab1', '/tabs/tab1'); + await waitForRouter(); + + guardDelay = 400; + + /* + * Tapping a link and then Tab 2 before the first one finishes. The push + * is cancelled, and its params used to be cleared along with the tab's, + * which cost the tab its direction and its tab name. + */ + navManager.handleNavigate('/details', 'push', 'forward'); + await new Promise((resolve) => setTimeout(resolve, 50)); + navManager.changeTab('tab2', '/tabs/tab2'); + await new Promise((resolve) => setTimeout(resolve, 1200)); + + const routeInfo = navManager.getCurrentRouteInfo(); + + expect(routeInfo.pathname).toEqual('/tabs/tab2'); + expect(routeInfo.routerDirection).toEqual('none'); + expect(routeInfo.tab).toEqual('tab2'); + // Losing the tab takes the pushed branch, which borrows the previous tab. + expect(routeInfo.pushedByRoute).toEqual(undefined); + }); + + // Guards against clearing a delta that belongs to another navigation still in flight. + it('should keep the delta of a back navigation that replaced a cancelled one', async () => { + let navManager: any; + const Home = { + ...createPage('home'), + setup() { + navManager = inject('navManager'); + } + }; + const First = createPage('first'); + const Second = createPage('second'); + + let racing = false; + let releaseFirstBack!: () => void; + let releaseSecondBack!: () => void; + let firstBackReachedGuard: () => void; + let secondBackReachedGuard: () => void; + let cancelReported: () => void; + + const firstBackStarted = new Promise((resolve) => { + firstBackReachedGuard = resolve as () => void; + }); + const secondBackStarted = new Promise((resolve) => { + secondBackReachedGuard = resolve as () => void; + }); + const firstBackCancelled = new Promise((resolve) => { + cancelReported = resolve as () => void; + }); + + const router = createRouter({ + history: createWebHistory(process.env.BASE_URL), + routes: [ + { path: '/', redirect: '/home' }, + { path: '/home', component: Home }, + { path: '/first', component: First }, + { path: '/second', component: Second } + ] + }); + + /* + * Both back navigations are held inside their guards so the test controls + * which one settles first. The second one stages its own navigation info as + * soon as its popstate lands, which is why the first one must not clear it. + */ + router.beforeEach(async (to) => { + if (!racing) { + return true; + } + + if (to.path === '/first') { + firstBackReachedGuard(); + await new Promise((resolve) => { + releaseFirstBack = resolve as () => void; + }); + } + + if (to.path === '/home') { + secondBackReachedGuard(); + await new Promise((resolve) => { + releaseSecondBack = resolve as () => void; + }); + } + + return true; + }); + + router.afterEach((_to, _from, failure) => { + if (isNavigationFailure(failure, NavigationFailureType.cancelled)) { + cancelReported(); + } + }); + + router.push('/'); + await router.isReady(); + const wrapper = mount(IonRouterOutlet, { + global: { + plugins: [router, IonicVue] + } + }); + + router.push('/first'); + await waitForRouter(); + router.push('/second'); + await waitForRouter(); + + racing = true; + + // First back, held until the second one is also in flight. + router.back(); + await firstBackStarted; + + // Second back, which replaces the first and stages its own info. + router.back(); + await secondBackStarted; + + // Let the first back finish, which reports it as cancelled. + releaseFirstBack(); + await firstBackCancelled; + + // Only now let the second back finish. + releaseSecondBack(); + await waitForRouter(); + + expect(currentRoute(navManager)).toEqual({ + pathname: '/home', + routerAction: 'pop', + routerDirection: 'back' + }); + expect(viewStack(wrapper)).toEqual([ + { id: 'home', hidden: false }, + { id: 'first', hidden: true } + ]); + }); });