Skip to content
22 changes: 16 additions & 6 deletions packages/react-router/src/ReactRouter/ReactRouterViewStack.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ import { analyzeRouteChildren, computeParentPath } from './utils/computeParentPa
import { derivePathnameToMatch, matchPath } from './utils/pathMatching';
import { normalizePathnameForComparison } from './utils/pathNormalization';
import { extractRouteChildren, isNavigateElement } from './utils/routeElements';
import { sortViewsBySpecificity } from './utils/viewItemUtils';
import { isForwardPush, isSwipeRevealed, sortViewsBySpecificity } from './utils/viewItemUtils';

/**
* Delay in milliseconds before removing a Navigate view item after a redirect.
Expand Down Expand Up @@ -454,7 +454,7 @@ export class ReactRouterViewStack extends ViewStacks {

// Deactivate wildcard (catch-all) and empty-path (default) routes when a more-specific route matches.
// This prevents "Not found" or fallback pages from showing alongside valid routes.
if (routePath === '*' || routePath === '') {
if (routePath === '*' || routePath === '/*' || routePath === '') {
// Check if any other view in this outlet has a match for the current route
const outletViews = this.getViewItemsForOutlet(viewItem.outletId);

Expand Down Expand Up @@ -510,8 +510,15 @@ export class ReactRouterViewStack extends ViewStacks {
}
}

if (hasSpecificMatch) {
viewItem.mount = false;
if (hasSpecificMatch && !isSwipeRevealed(viewItem)) {
// A splat can be the outlet's container page rather than a "not found" fallback.
// Pushed over it is the page underneath, so unmounting it destroys its state and
// leaves nothing for back to reveal. Hiding it below covers that. A view with no
// ion-page was never a page in the stack and has nothing to hide, so it still
// unmounts.
if (!isForwardPush(routeInfo) || !viewItem.ionPageElement) {
viewItem.mount = false;
}
if (viewItem.ionPageElement) {
viewItem.ionPageElement.classList.add('ion-page-hidden');
viewItem.ionPageElement.setAttribute('aria-hidden', 'true');
Expand Down Expand Up @@ -629,8 +636,11 @@ export class ReactRouterViewStack extends ViewStacks {

// Persist the mount path for subsequent calls, mirroring StackManager.outletMountPath.
// Unlike outletParentPaths (cleared when parentPath is undefined), the mount path is
// intentionally sticky — it anchors the outlet's scope and is only removed in clear().
if (result.outletMountPath && !this.outletMountPaths.has(outletId)) {
// intentionally sticky, it anchors the outlet's scope and is only removed in clear().
// A root outlet is skipped because it is mounted under nothing, so an inferred path
// would scope it to whatever route was active. parentPathnameBase is undefined
// exactly when the outlet has no parent matches.
if (parentPathnameBase && result.outletMountPath && !this.outletMountPaths.has(outletId)) {
this.outletMountPaths.set(outletId, result.outletMountPath);
}
}
Expand Down
83 changes: 64 additions & 19 deletions packages/react-router/src/ReactRouter/StackManager.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import {
import { derivePathnameToMatch, matchPath } from './utils/pathMatching';
import { stripTrailingSlash } from './utils/pathNormalization';
import { extractRouteChildren, getRoutesChildren, isNavigateElement } from './utils/routeElements';
import { clearSwipeRevealed, isForwardPush, isOverMatchingRoute, markSwipeRevealed } from './utils/viewItemUtils';

/**
* Delay in milliseconds before unmounting a view after a transition completes.
Expand Down Expand Up @@ -118,6 +119,8 @@ export class StackManager extends React.PureComponent<StackManagerProps> {
routerOutletElement: HTMLIonRouterOutletElement | undefined;
prevProps?: StackManagerProps;
skipTransition: boolean;
/** The view item the in-flight swipe gesture revealed, so the same one gets cleared. */
private swipeRevealedViewItem?: ViewItem;

stackContextValue: StackContextState = {
registerIonPage: this.registerIonPage.bind(this),
Expand Down Expand Up @@ -219,7 +222,9 @@ export class StackManager extends React.PureComponent<StackManagerProps> {
hasWildcardRoute,
});

if (result.outletMountPath && !this.outletMountPath) {
// A root outlet is mounted under nothing, so caching the inferred path would scope
// it to whatever route was active and make every other pathname look out of scope.
if (!this.isRootOutlet && result.outletMountPath && !this.outletMountPath) {
this.outletMountPath = result.outletMountPath;
}

Expand Down Expand Up @@ -297,14 +302,19 @@ export class StackManager extends React.PureComponent<StackManagerProps> {
}

// For non-replace actions, only unmount for back navigation
const isForwardPush = routeInfo.routeAction === 'push' && (routeInfo as any).routeDirection === 'forward';
if (!isForwardPush && routeInfo.routeDirection !== 'none' && enteringViewItem !== leavingViewItem) {
if (!isForwardPush(routeInfo) && routeInfo.routeDirection !== 'none' && enteringViewItem !== leavingViewItem) {
return true;
}

return false;
}

/** Clears the reveal flag from the view item the gesture marked. */
private clearSwipeRevealedView(): void {
clearSwipeRevealed(this.swipeRevealedViewItem);
this.swipeRevealedViewItem = undefined;
}

/**
* Handles out-of-scope outlet. Returns true if transition should be aborted.
*/
Expand Down Expand Up @@ -524,7 +534,9 @@ export class StackManager extends React.PureComponent<StackManagerProps> {
const previousInContainer =
routeInfo.lastPathname.startsWith(containerBase + '/') || routeInfo.lastPathname === containerBase;

if (currentInContainer && previousInContainer) {
// A root-level "/*" leaves an empty base, so both checks above are true for every
// pathname and the shortcut would otherwise skip every navigation in this outlet.
if (containerBase !== '' && currentInContainer && previousInContainer) {
const updatedMatch = matchComponent(
enteringViewItem.reactElement,
routeInfo.pathname,
Expand Down Expand Up @@ -1085,6 +1097,7 @@ export class StackManager extends React.PureComponent<StackManagerProps> {
}
this.waitingForIonPage = false;
this.preservedViewItems.clear();
this.clearSwipeRevealedView();

// Hide all views in this outlet before clearing.
// This is critical for nested outlets - when the parent component unmounts,
Expand Down Expand Up @@ -1120,15 +1133,41 @@ export class StackManager extends React.PureComponent<StackManagerProps> {
return;
}

// A completed swipe keeps its reveal flag past goBack() so the deactivation scan does
// not re-hide the page between the gesture ending and the URL settling. Clear it at
// the start of the transition for the new pathname.
this.clearSwipeRevealedView();

// Find entering and leaving view items
const viewItems = this.findViewItems(routeInfo);
let enteringViewItem = viewItems.enteringViewItem;
let leavingViewItem = viewItems.leavingViewItem;
let shouldUnmountLeavingViewItem = this.shouldUnmountLeavingView(routeInfo, enteringViewItem, leavingViewItem);

// Get parent path for nested outlets
const parentPath = this.getParentPath();

// Find the matching route element. This is React Router's own ranking of the outlet's
// routes, so it decides which route owns the pathname.
const enteringRoute = findRouteByRouteInfo(this.ionRouterOutlet?.props.children, routeInfo, parentPath) as
| React.ReactElement
| undefined;

// The lookup above only matches view items that already exist, so a sibling route with
// no view item yet never wins there and an over-matching route (splat, index, empty
// path) comes back for pathnames it does not own. Drop it rather than reusing it,
// because overwriting its reactElement below would swap its page for the winning
// route's and unmount it. This has to resolve before shouldUnmountLeavingView and
// handleRootNavigation, which both branch on the entering view item.
const matchedRouteProps = enteringViewItem?.routeData?.childProps;
const matchedAnOverMatchingRoute = isOverMatchingRoute(matchedRouteProps ?? {});
const routeOwnerDisagrees = enteringRoute !== undefined && enteringRoute.props.path !== matchedRouteProps?.path;

if (enteringViewItem && matchedAnOverMatchingRoute && routeOwnerDisagrees) {
enteringViewItem = undefined;
}

let shouldUnmountLeavingViewItem = this.shouldUnmountLeavingView(routeInfo, enteringViewItem, leavingViewItem);

// Handle out-of-scope outlet (route outside mount path)
if (this.handleOutOfScopeOutlet(routeInfo)) {
return;
Expand All @@ -1152,13 +1191,6 @@ export class StackManager extends React.PureComponent<StackManagerProps> {
return;
}

// Find the matching route element
const enteringRoute = findRouteByRouteInfo(
this.ionRouterOutlet?.props.children,
routeInfo,
parentPath
) as React.ReactElement;

// Handle nested outlet with no matching route
if (this.handleNoMatchingRoute(enteringRoute, enteringViewItem, leavingViewItem)) {
return;
Expand Down Expand Up @@ -1384,22 +1416,22 @@ export class StackManager extends React.PureComponent<StackManagerProps> {
const { routeInfo } = this.props;
const swipeBackRouteInfo = this.getSwipeBackRouteInfo();
const enteringViewItem = this.findEnteringViewForSwipe(swipeBackRouteInfo);
const leavingViewItem = this.context.findViewItemByRouteInfo(routeInfo, this.id, false);

// View might have mount=false but ionPageElement still in DOM
const ionPageInDocument = Boolean(
enteringViewItem?.ionPageElement && document.body.contains(enteringViewItem.ionPageElement)
);

// For wildcard/parameterized routes, the pattern path (e.g. "/foo/*") will
// never equal the resolved pathname (e.g. "/foo/bar"), so the pattern check
// alone isn't sufficient. Also, verify the entering view's resolved pathname
// differs from the current pathname — if they match, the entering and leaving
// views are the same and the swipe gesture shouldn't start.
// A splat consumes whatever is left of the pathname, so its match resolves to the whole
// current pathname and comparing pathnames can't tell a container page underneath a
// pushed sibling from the page being left. Compare the view items instead, like onEnd
// does. Without a leaving view onStart skips the transition, so reject that here too.
const canStartSwipe =
!!enteringViewItem &&
!!leavingViewItem &&
(enteringViewItem.mount || ionPageInDocument) &&
enteringViewItem.routeData.match.pattern.path !== routeInfo.pathname &&
enteringViewItem.routeData.match.pathname !== routeInfo.pathname;
enteringViewItem !== leavingViewItem;

debug('SwipeBackCanStart', () => ({
outletId: this.id,
Expand All @@ -1409,13 +1441,18 @@ export class StackManager extends React.PureComponent<StackManagerProps> {
enteringViewPath: enteringViewItem?.reactElement?.props?.path,
enteringMount: enteringViewItem?.mount,
ionPageInDocument,
leavingViewId: leavingViewItem?.id,
canStartSwipe,
}));

return canStartSwipe;
};

const onStart = async () => {
// Core only calls onEnd when a progress animation exists, so with animations off a
// previous gesture can leave its mark behind. Drop any stale one before marking.
this.clearSwipeRevealedView();

const { routeInfo } = this.props;
const swipeBackRouteInfo = this.getSwipeBackRouteInfo();
const enteringViewItem = this.findEnteringViewForSwipe(swipeBackRouteInfo);
Expand Down Expand Up @@ -1446,6 +1483,12 @@ export class StackManager extends React.PureComponent<StackManagerProps> {

// When the gesture starts, kick off a transition controlled via swipe gesture
if (enteringViewItem && leavingViewItem) {
// Without a leaving view there is no progress animation, and core only calls
// swipeHandler.onEnd when one exists, so nothing would clear the mark until the
// next transition.
markSwipeRevealed(enteringViewItem);
this.swipeRevealedViewItem = enteringViewItem;

await this.transitionPage(routeInfo, enteringViewItem, leavingViewItem, 'back', true);
}

Expand Down Expand Up @@ -1473,6 +1516,8 @@ export class StackManager extends React.PureComponent<StackManagerProps> {
const enteringViewItem = this.findEnteringViewForSwipe(swipeBackRouteInfo);
const leavingViewItem = this.context.findViewItemByRouteInfo(routeInfo, this.id, false);

this.clearSwipeRevealedView();

// Don't hide if entering and leaving are the same (parameterized route edge case)
if (enteringViewItem !== leavingViewItem && enteringViewItem?.ionPageElement !== undefined) {
hideIonPageElement(enteringViewItem.ionPageElement);
Expand Down
44 changes: 43 additions & 1 deletion packages/react-router/src/ReactRouter/utils/viewItemUtils.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { ViewItem } from '@ionic/react';
import type { RouteInfo, ViewItem } from '@ionic/react';

/**
* Compares two routes by specificity for sorting (most specific first).
Expand Down Expand Up @@ -37,6 +37,48 @@ export const compareRouteSpecificity = (
return 0;
};

/**
* True when a route matches more pathnames than its own path, so a splat, an index route,
* or a route with an empty or absent path. A lookup can return one of these for a pathname
* a more specific sibling owns, so callers must confirm ownership against React Router's
* ranking before reusing the view item.
*
* This is deliberately wider than the catch-all checks in ReactRouterViewStack, which each
* gate on a narrower shape for a different reason. Don't unify them with this helper.
*/
export const isOverMatchingRoute = (route: { path?: string; index?: boolean }): boolean => {
const { path, index } = route;
return !path || path.includes('*') || !!index;
};

/** True when the navigation pushed a new page forward on top of the current one. */
export const isForwardPush = (routeInfo: Pick<RouteInfo, 'routeAction' | 'routeDirection'>): boolean =>
routeInfo.routeAction === 'push' && routeInfo.routeDirection === 'forward';

const swipeRevealed = new WeakSet<ViewItem>();

/**
* Marks the page a swipe-back gesture has revealed. For the length of the drag that page
* is on screen while a more specific sibling still matches the current pathname, and the
* deactivation scan in `renderViewItem` would otherwise re-hide it on the next render and
* leave the user dragging a blank page.
*/
export const markSwipeRevealed = (viewItem: ViewItem | undefined): void => {
if (viewItem) {
swipeRevealed.add(viewItem);
}
};

/** Drops the mark once the gesture ends, so the view is hidden normally again. */
export const clearSwipeRevealed = (viewItem: ViewItem | undefined): void => {
if (viewItem) {
swipeRevealed.delete(viewItem);
}
};

/** True while a swipe-back gesture is showing this view. */
export const isSwipeRevealed = (viewItem: ViewItem): boolean => swipeRevealed.has(viewItem);

/**
* Sorts view items by route specificity (most specific first).
*
Expand Down
35 changes: 12 additions & 23 deletions packages/react-router/test/base/src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,25 +1,9 @@
import { IonApp, setupIonicReact, LogLevel, IonRouterOutlet } from '@ionic/react';
import { IonApp, IonRouterOutlet } from '@ionic/react';
import React from 'react';
import { Route, Navigate } from 'react-router-dom';

/* Core CSS required for Ionic components to work properly */
import '@ionic/react/css/core.css';

/* Basic CSS for apps built with Ionic */
import '@ionic/react/css/normalize.css';
import '@ionic/react/css/structure.css';
import '@ionic/react/css/typography.css';

/* Optional CSS utils that can be commented out */
import '@ionic/react/css/display.css';
import '@ionic/react/css/flex-utils.css';
import '@ionic/react/css/float-elements.css';
import '@ionic/react/css/padding.css';
import '@ionic/react/css/text-alignment.css';
import '@ionic/react/css/text-transformation.css';

/* Theme variables */
import './theme/variables.css';
/* Ionic CSS and setupIonicReact */
import './ionic-setup';
import Main from './pages/Main';

import { IonReactRouter } from '@ionic/react-router';
Expand Down Expand Up @@ -66,14 +50,16 @@ import { Step1, Step2, Step3, Step4 } from './pages/replace-params/ReplaceParams
import { ParamSwipeBack, ParamSwipeBackB } from './pages/param-swipe-back/ParamSwipeBack';
import TabLifecycle from './pages/tab-lifecycle/TabLifecycle';
import TabLifecycleOutside from './pages/tab-lifecycle/TabLifecycleOutside';
import { RouterLinkModifierClick, RouterLinkModifierClickTarget } from './pages/router-link-modifier-click/RouterLinkModifierClick';
import {
RouterLinkModifierClick,
RouterLinkModifierClickTarget,
} from './pages/router-link-modifier-click/RouterLinkModifierClick';
import { NavigateRootPageA, NavigateRootPageB, NavigateRootPageC } from './pages/navigate-root/NavigateRoot';
import SuspenseOutlet from './pages/suspense-outlet/SuspenseOutlet';
import { PropsUpdateDirect, PropsUpdateRoutesWrapper } from './pages/props-update/PropsUpdate';
import DisabledButton from './pages/disabled-button/DisabledButton';

// Debug logs on so failing specs include the navigation diagnostics.
setupIonicReact({ logLevel: LogLevel.DEBUG });
import SplatSibling from './pages/splat-sibling/SplatSibling';
import { EmptyPathSibling, IndexSibling } from './pages/index-sibling/IndexSibling';

const App: React.FC = () => {
return (
Expand Down Expand Up @@ -142,6 +128,9 @@ const App: React.FC = () => {
<Route path="/suspense-outlet/*" element={<SuspenseOutlet />} />
<Route path="/props-update-routes/*" element={<PropsUpdateRoutesWrapper />} />
<Route path="/props-update-direct/*" element={<PropsUpdateDirect />} />
<Route path="/splat-sibling/*" element={<SplatSibling />} />
<Route path="/index-sibling/*" element={<IndexSibling />} />
<Route path="/empty-path-sibling/*" element={<EmptyPathSibling />} />
</IonRouterOutlet>
</IonReactRouter>
</IonApp>
Expand Down
16 changes: 11 additions & 5 deletions packages/react-router/test/base/src/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,17 @@ import React from 'react';
import { createRoot } from 'react-dom/client';

import App from './App';
import RootSplatSiblingApp from './root-splat-sibling/RootSplatSiblingApp';
import { ROOT_SPLAT_SIBLING_BASENAME } from './root-splat-sibling/basename';

/**
* A root-level splat route swallows every pathname in its outlet, so it can't share App's
* route tree. It gets its own root, picked here by pathname before anything renders.
*/
const { pathname } = window.location;
const isRootSplatSibling =
pathname === ROOT_SPLAT_SIBLING_BASENAME || pathname.startsWith(`${ROOT_SPLAT_SIBLING_BASENAME}/`);

const container = document.getElementById('root');
const root = createRoot(container!);
root.render(
<React.StrictMode>
<App />
</React.StrictMode>
);
root.render(<React.StrictMode>{isRootSplatSibling ? <RootSplatSiblingApp /> : <App />}</React.StrictMode>);
Loading
Loading