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 }
+ ]);
+ });
});