Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
e023609
fix(vue-router): clear navigation info when a guard aborts navigation
thetaPC Aug 18, 2026
6d5c199
fix(vue-router): clear staged route params when a guard aborts naviga…
thetaPC Aug 18, 2026
7ae3b16
docs(vue-router): clarify which navigation failures clear staged state
thetaPC Aug 18, 2026
b6c6e73
fix(vue-router): clear staged state when a navigation is cancelled
thetaPC Aug 18, 2026
d4b89b7
test(vue-router): use data-pageid in routing specs
thetaPC Aug 18, 2026
4bbcc7a
test(vue-router): align routing spec setup with the rest of the file
thetaPC Aug 18, 2026
2dc6efd
test(vue-router): tidy the navigation guard specs
thetaPC Aug 18, 2026
7ff2c5e
fix(vue-router): only clear staged state from the failed navigation
thetaPC Aug 20, 2026
b1b2aca
Merge branch 'main' of github.com:ionic-team/ionic-framework into FW-…
thetaPC Aug 20, 2026
1b50d95
docs(vue-router): correct what stages route params and when entries r…
thetaPC Aug 21, 2026
101143e
test(vue-router): cover the back button path and fix a misleading spe…
thetaPC Aug 21, 2026
64a77a6
test(vue-router): read the router through inject instead of the Vue i…
thetaPC Aug 21, 2026
398cb36
test(vue-router): add exclamation
thetaPC Aug 21, 2026
06e0174
test(vue-router): hoist the shared page factory to module scope
thetaPC Aug 21, 2026
67125fe
fix(vue-router): record the target of staged route params
thetaPC Aug 25, 2026
6856c6f
test(vue-router): add definite assignment assertions to the racing specs
thetaPC Aug 25, 2026
6a6032f
docs(vue-router): use a doc block for the staged state comment
thetaPC Aug 25, 2026
f40d2d1
test(vue-router): link only the specs that reproduce the issue
thetaPC Aug 25, 2026
f469679
test(vue-router): assert the view stack and share the spec helpers
thetaPC Aug 25, 2026
150ecb2
fix(vue-router): stage route params through a single writer
thetaPC Aug 26, 2026
f1f6a33
fix(vue-router): discard staged state when a guard rejects
thetaPC Aug 26, 2026
ec47c2f
test(vue-router): cover a blocked replace leaking its params
thetaPC Aug 26, 2026
1b77808
fix(vue-router): reconcile staged params with redirects and query enc…
thetaPC Aug 26, 2026
c85f647
refactor(vue-router): clear the staged params through a single helper
thetaPC Aug 26, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
193 changes: 165 additions & 28 deletions packages/vue-router/src/router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ export const createIonRouter = (
direction: undefined,
action: undefined,
delta: undefined,
to: undefined,
};

/**
Expand All @@ -46,7 +47,11 @@ export const createIonRouter = (
_: RouteLocationNormalized,
failure?: NavigationFailure | void
) => {
if (failure) return;
if (failure) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

An async guard that throws instead of returning false never gets here at all, so the original bug still reproduces. Internally triggerError hands back a rejected promise, so the .then that would call triggerAfterEach never runs and the .catch(noop) on the end eats it. No failure object, no afterEach.

You don't need a deliberate throw either, an await on a session check that rejects does it. On /profile, tap back, guard throws, then tap a link to /settings: the URL says /settings but Ionic reports /home with a pop, and the stack collapses down to just Home, so Settings never mounts and Profile gets destroyed. Same on main so nothing regressed, it's just not covered.

Different thing from FW-7699, that one's the guard-returning-a-location case. I think a router.onError doing the same two gated clears would close it, and onError doesn't swallow the error so it wouldn't change any navigation outcomes. Feels cheap enough to do here, but I'm fine with a card if you'd rather keep this PR tight.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

discardStagedStateFor(to);

return;
}

const { direction, action, delta } = currentNavigationInfo;

Expand All @@ -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();

/**
Expand All @@ -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[] = [];

Expand All @@ -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
Expand All @@ -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,
};
});

Expand All @@ -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 ||
/**
Expand Down Expand Up @@ -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) {
Expand All @@ -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);
Expand Down Expand Up @@ -585,7 +711,7 @@ export const createIonRouter = (

currentRouteInfo = routeInfo;
}
incomingRouteParams = undefined;
clearStagedParams();
historyChangeListeners.forEach((cb) => cb(currentRouteInfo));
};

Expand All @@ -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);
};
Expand Down Expand Up @@ -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
Expand All @@ -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,
Expand Down Expand Up @@ -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) => {
Expand Down
6 changes: 6 additions & 0 deletions packages/vue-router/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Loading
Loading