diff --git a/packages/react-router/src/ReactRouter/ReactRouterViewStack.tsx b/packages/react-router/src/ReactRouter/ReactRouterViewStack.tsx index 32598cb22ba..fc1df86f6e8 100644 --- a/packages/react-router/src/ReactRouter/ReactRouterViewStack.tsx +++ b/packages/react-router/src/ReactRouter/ReactRouterViewStack.tsx @@ -384,30 +384,38 @@ export class ReactRouterViewStack extends ViewStacks { // For relative route paths, we need to compute an absolute pathnameBase // by combining the parent's pathnameBase with the matched portion - let absolutePathnameBase = routeMatch?.pathnameBase || routeInfo.pathname; const routePath = routeElement.props.path; const isRelativePath = routePath && !routePath.startsWith('/'); const isIndexRoute = !!routeElement.props.index; - - if (isRelativePath || isIndexRoute) { - // Get the parent's pathnameBase to build the absolute path - const parentPathnameBase = - parentMatches.length > 0 ? parentMatches[parentMatches.length - 1].pathnameBase : '/'; - - // For relative paths, the matchPath returns a relative pathnameBase - // We need to make it absolute by prepending the parent's base - if (routeMatch?.pathnameBase && isRelativePath) { - // Strip leading slash if present in the relative match - const relativeBase = routeMatch.pathnameBase.startsWith('/') - ? routeMatch.pathnameBase.slice(1) - : routeMatch.pathnameBase; - - absolutePathnameBase = - parentPathnameBase === '/' ? `/${relativeBase}` : `${parentPathnameBase}/${relativeBase}`; - } else if (isIndexRoute) { - // Index routes should use the parent's base as their base - absolutePathnameBase = parentPathnameBase; - } + const isSplatOnlyRoute = routePath === '*' || routePath === '/*'; + + // Get parent's pathnameBase for relative path resolution + const parentPathnameBase = + parentMatches.length > 0 ? parentMatches[parentMatches.length - 1].pathnameBase : '/'; + + // Start with the match's pathnameBase, falling back to routeInfo.pathname + // BUT: splat-only routes should use parent's base (v7_relativeSplatPath behavior) + let absolutePathnameBase: string; + + if (isSplatOnlyRoute) { + // Splat routes should NOT contribute their matched portion to pathnameBase + // This aligns with React Router v7's v7_relativeSplatPath behavior + // Without this, relative links inside splat routes get double path segments + absolutePathnameBase = parentPathnameBase; + } else if (isRelativePath && routeMatch?.pathnameBase) { + // For relative paths with a pathnameBase, combine with parent + const relativeBase = routeMatch.pathnameBase.startsWith('/') + ? routeMatch.pathnameBase.slice(1) + : routeMatch.pathnameBase; + + absolutePathnameBase = + parentPathnameBase === '/' ? `/${relativeBase}` : `${parentPathnameBase}/${relativeBase}`; + } else if (isIndexRoute) { + // Index routes should use the parent's base as their base + absolutePathnameBase = parentPathnameBase; + } else { + // Default: use the match's pathnameBase or the current pathname + absolutePathnameBase = routeMatch?.pathnameBase || routeInfo.pathname; } const contextMatches = [ @@ -469,7 +477,9 @@ export class ReactRouterViewStack extends ViewStacks { let parentPath: string | undefined = undefined; try { // Only attempt parent path computation for non-root outlets - if (outletId !== 'routerOutlet') { + // Root outlets have IDs like 'routerOutlet' or 'routerOutlet-2' + const isRootOutlet = outletId.startsWith('routerOutlet'); + if (!isRootOutlet) { const routeChildren = extractRouteChildren(ionRouterOutlet.props.children); const { hasRelativeRoutes, hasIndexRoute, hasWildcardRoute } = analyzeRouteChildren(routeChildren); @@ -713,7 +723,17 @@ export class ReactRouterViewStack extends ViewStacks { return false; } + // For empty path routes, only match if we're at the same level as when the view was created. + // This prevents an empty path view item from being reused for different routes. if (isDefaultRoute) { + const previousPathnameBase = v.routeData?.match?.pathnameBase || ''; + const normalizedBase = normalizePathnameForComparison(previousPathnameBase); + const normalizedPathname = normalizePathnameForComparison(pathname); + + if (normalizedPathname !== normalizedBase) { + return false; + } + match = { params: {}, pathname, diff --git a/packages/react-router/src/ReactRouter/StackManager.tsx b/packages/react-router/src/ReactRouter/StackManager.tsx index 4ce73c561b0..24fbba961f7 100644 --- a/packages/react-router/src/ReactRouter/StackManager.tsx +++ b/packages/react-router/src/ReactRouter/StackManager.tsx @@ -109,28 +109,36 @@ export class StackManager extends React.PureComponent { return undefined; } - // If this is a nested outlet (has an explicit ID like "main"), - // we need to figure out what part of the path was already matched - if (this.id !== 'routerOutlet' && this.ionRouterOutlet) { + // Check if this outlet has route children to analyze + if (this.ionRouterOutlet) { const routeChildren = extractRouteChildren(this.ionRouterOutlet.props.children); const { hasRelativeRoutes, hasIndexRoute, hasWildcardRoute } = analyzeRouteChildren(routeChildren); - const result = computeParentPath({ - currentPathname, - outletMountPath: this.outletMountPath, - routeChildren, - hasRelativeRoutes, - hasIndexRoute, - hasWildcardRoute, - }); + // Root outlets have IDs like 'routerOutlet' or 'routerOutlet-2' + // But even outlets with auto-generated IDs may need parent path computation + // if they have relative routes (indicating they're nested outlets) + const isRootOutlet = this.id.startsWith('routerOutlet'); + const needsParentPath = !isRootOutlet || hasRelativeRoutes || hasIndexRoute; + + if (needsParentPath) { + const result = computeParentPath({ + currentPathname, + outletMountPath: this.outletMountPath, + routeChildren, + hasRelativeRoutes, + hasIndexRoute, + hasWildcardRoute, + }); + + // Update the outlet mount path if it was set + if (result.outletMountPath && !this.outletMountPath) { + this.outletMountPath = result.outletMountPath; + } - // Update the outlet mount path if it was set - if (result.outletMountPath && !this.outletMountPath) { - this.outletMountPath = result.outletMountPath; + return result.parentPath; } - - return result.parentPath; } + return this.outletMountPath; } @@ -199,9 +207,40 @@ export class StackManager extends React.PureComponent { } if (routeInfo.routeAction === 'replace') { - return true; + const enteringRoutePath = enteringViewItem?.reactElement?.props?.path as string | undefined; + const leavingRoutePath = leavingViewItem?.reactElement?.props?.path as string | undefined; + + // Never unmount the root path "/" - it's the main entry point for back navigation + if (leavingRoutePath === '/' || leavingRoutePath === '') { + return false; + } + + if (enteringRoutePath && leavingRoutePath) { + // Get parent paths to check if routes share a common parent + const getParentPath = (path: string) => { + const normalized = path.replace(/\/\*$/, ''); // Remove trailing /* + const lastSlash = normalized.lastIndexOf('/'); + return lastSlash > 0 ? normalized.substring(0, lastSlash) : '/'; + }; + + const enteringParent = getParentPath(enteringRoutePath); + const leavingParent = getParentPath(leavingRoutePath); + + // Unmount if: + // 1. Routes are siblings (same parent, e.g., /page1 and /page2, or /foo/page1 and /foo/page2) + // 2. Entering is a child of leaving (redirect, e.g., /tabs -> /tabs/tab1) + const areSiblings = enteringParent === leavingParent && enteringParent !== '/'; + const isChildRedirect = + enteringRoutePath.startsWith(leavingRoutePath) || + (leavingRoutePath.endsWith('/*') && enteringRoutePath.startsWith(leavingRoutePath.slice(0, -2))); + + return areSiblings || isChildRedirect; + } + + return false; } + // For non-replace actions, only unmount for back navigation (not forward push) const isForwardPush = routeInfo.routeAction === 'push' && (routeInfo as any).routeDirection === 'forward'; if (!isForwardPush && routeInfo.routeDirection !== 'none' && enteringViewItem !== leavingViewItem) { return true; @@ -246,7 +285,9 @@ export class StackManager extends React.PureComponent { parentPath: string | undefined, leavingViewItem: ViewItem | undefined ): boolean { - if (this.id === 'routerOutlet' || parentPath !== undefined || !this.ionRouterOutlet) { + // Root outlets have IDs like 'routerOutlet' or 'routerOutlet-2' + const isRootOutlet = this.id.startsWith('routerOutlet'); + if (isRootOutlet || parentPath !== undefined || !this.ionRouterOutlet) { return false; } @@ -283,7 +324,9 @@ export class StackManager extends React.PureComponent { enteringViewItem: ViewItem | undefined, leavingViewItem: ViewItem | undefined ): boolean { - if (this.id === 'routerOutlet' || enteringRoute || enteringViewItem) { + // Root outlets have IDs like 'routerOutlet' or 'routerOutlet-2' + const isRootOutlet = this.id.startsWith('routerOutlet'); + if (isRootOutlet || enteringRoute || enteringViewItem) { return false; } @@ -305,9 +348,6 @@ export class StackManager extends React.PureComponent { leavingViewItem: ViewItem | undefined, shouldUnmountLeavingViewItem: boolean ): void { - // Ensure the entering view is not hidden from previous navigations - showIonPageElement(enteringViewItem.ionPageElement); - // Handle same view item case (e.g., parameterized route changes) if (enteringViewItem === leavingViewItem) { const routePath = enteringViewItem.reactElement?.props?.path as string | undefined; @@ -336,34 +376,93 @@ export class StackManager extends React.PureComponent { leavingViewItem = this.context.findViewItemByPathname(this.props.routeInfo.prevRouteLastPathname, this.id); } - // Skip transition if entering view is visible and leaving view is not - if ( - enteringViewItem.ionPageElement && - isViewVisible(enteringViewItem.ionPageElement) && - leavingViewItem !== undefined && - leavingViewItem.ionPageElement && - !isViewVisible(leavingViewItem.ionPageElement) - ) { - return; + // Ensure the entering view is marked as mounted. + // This is critical for views that were previously unmounted (e.g., navigating back to home). + // When mount=false, the ViewLifeCycleManager doesn't render the IonPage, so the + // ionPageElement reference becomes stale. By setting mount=true, we ensure the view + // gets re-rendered and a new IonPage is created. + if (!enteringViewItem.mount) { + enteringViewItem.mount = true; } + // Check visibility state BEFORE showing the entering view. + // This must be done before showIonPageElement to get accurate visibility state. + const enteringWasVisible = enteringViewItem.ionPageElement && isViewVisible(enteringViewItem.ionPageElement); + const leavingIsHidden = + leavingViewItem !== undefined && leavingViewItem.ionPageElement && !isViewVisible(leavingViewItem.ionPageElement); + // Check for duplicate transition const currentTransition = { enteringId: enteringViewItem.id, leavingId: leavingViewItem?.id, }; - if ( + const isDuplicateTransition = leavingViewItem && this.lastTransition && this.lastTransition.leavingId && this.lastTransition.enteringId === currentTransition.enteringId && - this.lastTransition.leavingId === currentTransition.leavingId - ) { + this.lastTransition.leavingId === currentTransition.leavingId; + + // Skip transition if entering view was ALREADY visible and leaving view is not visible. + // This indicates the transition has already been performed (e.g., via swipe gesture). + // IMPORTANT: Only skip if both ionPageElements are the same as when the transition was last done. + // If the leaving view's ionPageElement changed (e.g., component re-rendered with different IonPage), + // we should NOT skip because the DOM state is inconsistent. + if (enteringWasVisible && leavingIsHidden && isDuplicateTransition) { + // For swipe-to-go-back, the transition animation was handled by the gesture. + // We still need to set mount=false so React unmounts the leaving view. + // Only do this when skipTransition is set (indicating gesture completion). + if ( + this.skipTransition && + shouldUnmountLeavingViewItem && + leavingViewItem && + enteringViewItem !== leavingViewItem + ) { + leavingViewItem.mount = false; + // Call transitionPage with duration 0 to trigger ionViewDidLeave lifecycle + // which is needed for ViewLifeCycleManager to remove the view. + this.transitionPage(routeInfo, enteringViewItem, leavingViewItem, 'back'); + } + // Clear skipTransition since we're not calling transitionPage which normally clears it + this.skipTransition = false; + // Must call forceUpdate to trigger re-render after mount state change + this.forceUpdate(); + return; + } + + // Ensure the entering view is not hidden from previous navigations + // This must happen AFTER the visibility check above + showIonPageElement(enteringViewItem.ionPageElement); + + // Skip if this is a duplicate transition (but visibility state didn't match above) + // OR if skipTransition is set (swipe gesture already handled the animation) + if (isDuplicateTransition || this.skipTransition) { + // For swipe-to-go-back, we still need to handle unmounting even if visibility + // conditions aren't fully met (animation might still be in progress) + if ( + this.skipTransition && + shouldUnmountLeavingViewItem && + leavingViewItem && + enteringViewItem !== leavingViewItem + ) { + leavingViewItem.mount = false; + // For swipe-to-go-back, we need to call transitionPage with duration 0 to + // trigger the ionViewDidLeave lifecycle event. The ViewLifeCycleManager + // uses componentCanBeDestroyed callback to remove the view, which is + // only called from ionViewDidLeave. Since the gesture animation already + // completed before mount=false was set, we need to re-fire the lifecycle. + this.transitionPage(routeInfo, enteringViewItem, leavingViewItem, 'back'); + } + // Clear skipTransition since we're not calling transitionPage which normally clears it + this.skipTransition = false; + // Must call forceUpdate to trigger re-render after mount state change + this.forceUpdate(); return; } this.lastTransition = currentTransition; + this.transitionPage(routeInfo, enteringViewItem, leavingViewItem); // Handle unmounting the leaving view @@ -374,14 +473,29 @@ export class StackManager extends React.PureComponent { } /** - * Handles the delayed unmount of the leaving view item after a replace action. + * Handles the delayed unmount of the leaving view item. + * For 'replace' actions: handles container route transitions specially. + * For back navigation: explicitly unmounts because the ionViewDidLeave lifecycle + * fires DURING transitionPage, but mount=false is set AFTER. + * + * @param routeInfo Current route information + * @param enteringViewItem The view being navigated to + * @param leavingViewItem The view being navigated from */ private handleLeavingViewUnmount(routeInfo: RouteInfo, enteringViewItem: ViewItem, leavingViewItem: ViewItem): void { - if (routeInfo.routeAction !== 'replace' || !leavingViewItem.ionPageElement) { + if (!leavingViewItem.ionPageElement) { return; } - // Check if we should skip removal for nested outlet redirects + // For push/pop actions, do NOT unmount - views are cached for navigation history. + // Push: Forward navigation caches views for back navigation + // Pop: Back navigation should not unmount the entering view's history + // Only 'replace' actions should actually unmount views since they replace history. + if (routeInfo.routeAction !== 'replace') { + return; + } + + // For replace actions, check if we should skip removal for nested outlet redirects const enteringRoutePath = enteringViewItem.reactElement?.props?.path as string | undefined; const leavingRoutePath = leavingViewItem.reactElement?.props?.path as string | undefined; const isEnteringContainerRoute = enteringRoutePath && enteringRoutePath.endsWith('/*'); @@ -400,6 +514,8 @@ export class StackManager extends React.PureComponent { const viewToUnmount = leavingViewItem; setTimeout(() => { this.context.unMountViewItem(viewToUnmount); + // Trigger re-render to remove the view from DOM + this.forceUpdate(); }, VIEW_UNMOUNT_DELAY_MS); } @@ -460,6 +576,8 @@ export class StackManager extends React.PureComponent { if (shouldUnmountLeavingViewItem && latestLeavingView && latestEnteringView !== latestLeavingView) { latestLeavingView.mount = false; + // Call handleLeavingViewUnmount to ensure the view is properly removed + this.handleLeavingViewUnmount(routeInfo, latestEnteringView, latestLeavingView); } this.forceUpdate(); @@ -603,7 +721,14 @@ export class StackManager extends React.PureComponent { } // Handle transition based on ion-page element availability - if (enteringViewItem && enteringViewItem.ionPageElement) { + // Check if the ionPageElement is still in the document. + // If the view was previously unmounted (mount=false), the ViewLifeCycleManager + // removes the React component from the tree, which removes the IonPage from the DOM. + // The ionPageElement reference becomes stale and we need to wait for a new one. + const ionPageIsInDocument = + enteringViewItem?.ionPageElement && document.body.contains(enteringViewItem.ionPageElement); + + if (enteringViewItem && ionPageIsInDocument) { // Clear waiting state if (this.waitingForIonPage) { this.waitingForIonPage = false; @@ -614,8 +739,17 @@ export class StackManager extends React.PureComponent { } this.handleReadyEnteringView(routeInfo, enteringViewItem, leavingViewItem, shouldUnmountLeavingViewItem); - } else if (enteringViewItem && !enteringViewItem.ionPageElement) { + } else if (enteringViewItem && !ionPageIsInDocument) { // Wait for ion-page to mount + // This handles both: no ionPageElement, or stale ionPageElement (not in document) + // Clear stale reference if the element is no longer in the document + if (enteringViewItem.ionPageElement && !document.body.contains(enteringViewItem.ionPageElement)) { + enteringViewItem.ionPageElement = undefined; + } + // Ensure the view is marked as mounted so ViewLifeCycleManager renders the IonPage + if (!enteringViewItem.mount) { + enteringViewItem.mount = true; + } this.handleWaitingForIonPage(routeInfo, enteringViewItem, leavingViewItem, shouldUnmountLeavingViewItem); return; } else if (!enteringViewItem && !enteringRoute) { @@ -645,9 +779,26 @@ export class StackManager extends React.PureComponent { this.ionPageWaitTimeout = undefined; } this.pendingPageTransition = false; + const foundView = this.context.findViewItemByRouteInfo(routeInfo, this.id); + if (foundView) { const oldPageElement = foundView.ionPageElement; + + /** + * FIX for issue #28878: Reject orphaned IonPage registrations. + * + * When a component conditionally renders different IonPages (e.g., list vs empty state) + * using React keys, and state changes simultaneously with navigation, the new IonPage + * tries to register for a route we're navigating away from. This creates a stale view. + * + * Only reject if both pageIds exist and differ, to allow nested outlet registrations. + */ + if (this.shouldRejectOrphanedPage(page, oldPageElement, routeInfo)) { + this.hideAndRemoveOrphanedPage(page); + return; + } + foundView.ionPageElement = page; foundView.ionRoute = true; @@ -663,6 +814,45 @@ export class StackManager extends React.PureComponent { this.handlePageTransition(routeInfo); } + /** + * Determines if a new IonPage registration should be rejected as orphaned. + * This happens when a component re-renders with a different IonPage while navigating away. + */ + private shouldRejectOrphanedPage( + newPage: HTMLElement, + oldPageElement: HTMLElement | undefined, + routeInfo: RouteInfo + ): boolean { + if (!oldPageElement || oldPageElement === newPage) { + return false; + } + + const newPageId = newPage.getAttribute('data-pageid'); + const oldPageId = oldPageElement.getAttribute('data-pageid'); + + // Only reject if both pageIds exist and are different + if (!newPageId || !oldPageId || newPageId === oldPageId) { + return false; + } + + // Reject only if we're navigating away from this route + return this.props.routeInfo.pathname !== routeInfo.pathname; + } + + /** + * Hides an orphaned IonPage and schedules its removal from the DOM. + */ + private hideAndRemoveOrphanedPage(page: HTMLElement): void { + page.classList.add('ion-page-hidden'); + page.setAttribute('aria-hidden', 'true'); + + setTimeout(() => { + if (page.parentElement) { + page.remove(); + } + }, VIEW_UNMOUNT_DELAY_MS); + } + /** * Configures the router outlet for the swipe-to-go-back gesture. * @@ -679,13 +869,28 @@ export class StackManager extends React.PureComponent { const { routeInfo } = this.props; const swipeBackRouteInfo = this.getSwipeBackRouteInfo(); - const enteringViewItem = this.context.findViewItemByRouteInfo(swipeBackRouteInfo, this.id, false); + // First try to find the view in the current outlet + let enteringViewItem = this.context.findViewItemByRouteInfo(swipeBackRouteInfo, this.id, false); + // If not found in current outlet, search all outlets (for cross-outlet swipe back) + if (!enteringViewItem) { + enteringViewItem = this.context.findViewItemByRouteInfo(swipeBackRouteInfo, undefined, false); + } + + // Check if the ionPageElement is still in the document. + // A view might have mount=false but still have its ionPageElement in the DOM + // (due to timing differences in unmounting). + const ionPageInDocument = Boolean( + enteringViewItem?.ionPageElement && document.body.contains(enteringViewItem.ionPageElement) + ); const canStartSwipe = !!enteringViewItem && - // The root url '/' is treated as the first view item (but is never mounted), - // so we do not want to swipe back to the root url. - enteringViewItem.mount && + // Check if we can swipe to this view. Either: + // 1. The view is mounted (mount=true), OR + // 2. The view's ionPageElement is still in the document + // The second case handles views that have been marked for unmount but haven't + // actually been removed from the DOM yet. + (enteringViewItem.mount || ionPageInDocument) && // When on the first page it is possible for findViewItemByRouteInfo to // return the exact same view you are currently on. // Make sure that we are not swiping back to the same instances of a view. @@ -697,9 +902,20 @@ export class StackManager extends React.PureComponent { const onStart = async () => { const { routeInfo } = this.props; const swipeBackRouteInfo = this.getSwipeBackRouteInfo(); - const enteringViewItem = this.context.findViewItemByRouteInfo(swipeBackRouteInfo, this.id, false); + // First try to find the view in the current outlet, then search all outlets + let enteringViewItem = this.context.findViewItemByRouteInfo(swipeBackRouteInfo, this.id, false); + if (!enteringViewItem) { + enteringViewItem = this.context.findViewItemByRouteInfo(swipeBackRouteInfo, undefined, false); + } const leavingViewItem = this.context.findViewItemByRouteInfo(routeInfo, this.id, false); + // Ensure the entering view is mounted so React keeps rendering it during the gesture. + // This is important when the view was previously marked for unmount but its + // ionPageElement is still in the DOM. + if (enteringViewItem && !enteringViewItem.mount) { + enteringViewItem.mount = true; + } + // When the gesture starts, kick off a transition controlled via swipe gesture if (enteringViewItem && leavingViewItem) { await this.transitionPage(routeInfo, enteringViewItem, leavingViewItem, 'back', true); @@ -717,7 +933,11 @@ export class StackManager extends React.PureComponent { // Swipe gesture was aborted - re-hide the page that was going to enter const { routeInfo } = this.props; const swipeBackRouteInfo = this.getSwipeBackRouteInfo(); - const enteringViewItem = this.context.findViewItemByRouteInfo(swipeBackRouteInfo, this.id, false); + // First try to find the view in the current outlet, then search all outlets + let enteringViewItem = this.context.findViewItemByRouteInfo(swipeBackRouteInfo, this.id, false); + if (!enteringViewItem) { + enteringViewItem = this.context.findViewItemByRouteInfo(swipeBackRouteInfo, undefined, false); + } const leavingViewItem = this.context.findViewItemByRouteInfo(routeInfo, this.id, false); // Don't hide if entering and leaving are the same (parameterized route edge case) @@ -933,7 +1153,8 @@ function findRouteByRouteInfo(node: React.ReactNode, routeInfo: RouteInfo, paren // For nested routes in React Router 6, we need to extract the relative path // that this outlet should be responsible for matching - let pathnameToMatch = routeInfo.pathname; + const originalPathname = routeInfo.pathname; + let relativePathnameToMatch = routeInfo.pathname; // Check if we have relative routes (routes that don't start with '/') const hasRelativeRoutes = sortedRoutes.some((r) => r.props.path && !r.props.path.startsWith('/')); @@ -942,7 +1163,8 @@ function findRouteByRouteInfo(node: React.ReactNode, routeInfo: RouteInfo, paren // SIMPLIFIED: Trust React Router 6's matching more, compute relative path when parent is known if ((hasRelativeRoutes || hasIndexRoute) && parentPath) { const parentPrefix = parentPath.replace('/*', ''); - const normalizedParent = stripTrailingSlash(parentPrefix); + // Normalize both paths to start with '/' for consistent comparison + const normalizedParent = stripTrailingSlash(parentPrefix.startsWith('/') ? parentPrefix : `/${parentPrefix}`); const normalizedPathname = stripTrailingSlash(routeInfo.pathname); // Only compute relative path if pathname is within parent scope @@ -950,14 +1172,44 @@ function findRouteByRouteInfo(node: React.ReactNode, routeInfo: RouteInfo, paren const pathSegments = routeInfo.pathname.split('/').filter(Boolean); const parentSegments = normalizedParent.split('/').filter(Boolean); const relativeSegments = pathSegments.slice(parentSegments.length); - pathnameToMatch = relativeSegments.join('/'); // Empty string is valid for index routes + relativePathnameToMatch = relativeSegments.join('/'); // Empty string is valid for index routes } } // Find the first matching route for (const child of sortedRoutes) { + const childPath = child.props.path as string | undefined; + const isAbsoluteRoute = childPath && childPath.startsWith('/'); + + // Determine which pathname to match against: + // - For absolute routes: use the original full pathname + // - For relative routes with a parent: use the computed relative pathname + // - For relative routes at root level (no parent): use the original pathname + // (matchPath will handle the relative-to-absolute normalization) + const pathnameToMatch = isAbsoluteRoute ? originalPathname : relativePathnameToMatch; + + // Determine the path portion to match: + // - For absolute routes: use derivePathnameToMatch + // - For relative routes at root level (no parent): use original pathname + // directly since matchPath normalizes both path and pathname + // - For relative routes with parent: use derivePathnameToMatch for wildcards, + // or the computed relative pathname for non-wildcards + let pathForMatch: string; + if (isAbsoluteRoute) { + pathForMatch = derivePathnameToMatch(pathnameToMatch, childPath); + } else if (!parentPath && childPath) { + // Root-level relative route: use the full pathname and let matchPath + // handle the normalization (it adds '/' to both path and pathname) + pathForMatch = originalPathname; + } else if (childPath && childPath.includes('*')) { + // Relative wildcard route with parent path: use derivePathnameToMatch + pathForMatch = derivePathnameToMatch(pathnameToMatch, childPath); + } else { + pathForMatch = pathnameToMatch; + } + const match = matchPath({ - pathname: pathnameToMatch, + pathname: pathForMatch, componentProps: child.props, }); diff --git a/packages/react-router/src/ReactRouter/utils/computeParentPath.ts b/packages/react-router/src/ReactRouter/utils/computeParentPath.ts index 3f47672a445..338efc28347 100644 --- a/packages/react-router/src/ReactRouter/utils/computeParentPath.ts +++ b/packages/react-router/src/ReactRouter/utils/computeParentPath.ts @@ -44,26 +44,39 @@ export const computeCommonPrefix = (paths: string[]): string => { }; /** - * Checks if a route is a specific match (not wildcard or index). - * - * @param route The route element to check. - * @param remainingPath The remaining path to match against. - * @returns True if the route specifically matches the remaining path. + * Checks if a route path is a "splat-only" route (just `*` or `/*`). */ -export const isSpecificRouteMatch = (route: React.ReactElement, remainingPath: string): boolean => { - const routePath = route.props.path; - const isWildcardOnly = routePath === '*' || routePath === '/*'; - const isIndex = route.props.index; +const isSplatOnlyRoute = (routePath: string | undefined): boolean => { + return routePath === '*' || routePath === '/*'; +}; - // Skip wildcards and index routes - if (isIndex || isWildcardOnly) { +/** + * Checks if a route has an embedded wildcard (e.g., "tab1/*" but not "*" or "/*"). + */ +const hasEmbeddedWildcard = (routePath: string | undefined): boolean => { + return !!routePath && routePath.includes('*') && !isSplatOnlyRoute(routePath); +}; + +/** + * Checks if a route with an embedded wildcard matches a pathname. + */ +const matchesEmbeddedWildcardRoute = (route: React.ReactElement, pathname: string): boolean => { + const routePath = route.props.path as string | undefined; + if (!hasEmbeddedWildcard(routePath)) { return false; } + return !!matchPath({ pathname, componentProps: route.props }); +}; - return !!matchPath({ - pathname: remainingPath, - componentProps: route.props, - }); +/** + * Checks if a route is a specific match (not wildcard-only or index). + */ +export const isSpecificRouteMatch = (route: React.ReactElement, remainingPath: string): boolean => { + const routePath = route.props.path; + if (route.props.index || isSplatOnlyRoute(routePath)) { + return false; + } + return !!matchPath({ pathname: remainingPath, componentProps: route.props }); }; /** @@ -142,12 +155,16 @@ export const computeParentPath = (options: ComputeParentPathOptions): ParentPath let firstWildcardMatch: string | undefined = undefined; let indexMatchAtMount: string | undefined = undefined; + // Start at i = 1 (normal case: strip at least one segment for parent path) for (let i = 1; i <= segments.length; i++) { const parentPath = '/' + segments.slice(0, i).join('/'); const remainingPath = segments.slice(i).join('/'); - // Check for specific (non-wildcard, non-index) route matches - const hasSpecificMatch = routeChildren.some((route) => isSpecificRouteMatch(route, remainingPath)); + // Check for specific route matches (non-wildcard-only, non-index) + // Also check routes with embedded wildcards (e.g., "tab1/*") + const hasSpecificMatch = routeChildren.some( + (route) => isSpecificRouteMatch(route, remainingPath) || matchesEmbeddedWildcardRoute(route, remainingPath) + ); if (hasSpecificMatch && !firstSpecificMatch) { firstSpecificMatch = parentPath; // Found a specific match - this is our answer for non-index routes @@ -198,6 +215,17 @@ export const computeParentPath = (options: ComputeParentPathOptions): ParentPath } } + // Fallback: check at root level (i = 0) for embedded wildcard routes. + // This handles outlets inside root-level splat routes where routes like + // "tab1/*" need to match the full pathname. + if (!firstSpecificMatch) { + const fullRemainingPath = segments.join('/'); + const hasRootLevelMatch = routeChildren.some((route) => matchesEmbeddedWildcardRoute(route, fullRemainingPath)); + if (hasRootLevelMatch) { + firstSpecificMatch = '/'; + } + } + // Determine the best parent path: // 1. Specific match (routes like tabs/*, favorites) - highest priority // 2. Wildcard match (route path="*") - catches unmatched segments diff --git a/packages/react-router/src/ReactRouter/utils/pathMatching.ts b/packages/react-router/src/ReactRouter/utils/pathMatching.ts index a0ab74164f7..623564a407a 100644 --- a/packages/react-router/src/ReactRouter/utils/pathMatching.ts +++ b/packages/react-router/src/ReactRouter/utils/pathMatching.ts @@ -27,13 +27,8 @@ interface MatchPathOptions { export const matchPath = ({ pathname, componentProps }: MatchPathOptions): PathMatch | null => { const { path, index, ...restProps } = componentProps; - // Handle index routes + // Handle index routes - they match when pathname is empty or just "/" if (index && !path) { - // Index routes match when there's no additional path after the parent route - // For example, in a nested outlet at /routing/*, the index route matches - // when the relative path is empty (i.e., we're exactly at /routing) - - // If pathname is empty or just "/", it should match the index route if (pathname === '' || pathname === '/') { return { params: {}, @@ -46,17 +41,27 @@ export const matchPath = ({ pathname, componentProps }: MatchPathOptions): PathM }, }; } - - // Otherwise, index routes don't match when there's additional path return null; } - if (!path) { + // Handle empty path routes - they match when pathname is also empty or just "/" + if (path === '' || path === undefined) { + if (pathname === '' || pathname === '/') { + return { + params: {}, + pathname: pathname, + pathnameBase: pathname || '/', + pattern: { + path: '', + caseSensitive: restProps.caseSensitive ?? false, + end: restProps.end ?? true, + }, + }; + } return null; } - // For relative paths in nested routes (those that don't start with '/'), - // use React Router's matcher against a normalized path. + // For relative paths (don't start with '/'), normalize both path and pathname for matching if (!path.startsWith('/')) { const matchOptions: Parameters[0] = { path: `/${path}`, @@ -83,7 +88,6 @@ export const matchPath = ({ pathname, componentProps }: MatchPathOptions): PathM }; } - // No match found return null; } @@ -109,13 +113,17 @@ export const matchPath = ({ pathname, componentProps }: MatchPathOptions): PathM * strip off the already-matched parent segments so React Router receives the remainder. */ export const derivePathnameToMatch = (fullPathname: string, routePath?: string): string => { + // For absolute or empty routes, use the full pathname as-is if (!routePath || routePath === '' || routePath.startsWith('/')) { return fullPathname; } const trimmedPath = fullPathname.startsWith('/') ? fullPathname.slice(1) : fullPathname; if (!trimmedPath) { - return ''; + // For root-level relative routes (pathname is "/" and routePath is relative), + // return the full pathname so matchPath can normalize both. + // This allows routes like at root level to work correctly. + return fullPathname; } const fullSegments = trimmedPath.split('/').filter(Boolean); diff --git a/packages/react-router/test/base/src/App.tsx b/packages/react-router/test/base/src/App.tsx index a97c04f5d71..4c23dd576c7 100644 --- a/packages/react-router/test/base/src/App.tsx +++ b/packages/react-router/test/base/src/App.tsx @@ -31,6 +31,7 @@ import MultipleTabs from './pages/muiltiple-tabs/MultipleTabs'; import NestedOutlet from './pages/nested-outlet/NestedOutlet'; import NestedOutlet2 from './pages/nested-outlet/NestedOutlet2'; import NestedParams from './pages/nested-params/NestedParams'; +import RelativePaths from './pages/relative-paths/RelativePaths'; import { OutletRef } from './pages/outlet-ref/OutletRef'; import Params from './pages/params/Params'; import Refs from './pages/refs/Refs'; @@ -42,6 +43,9 @@ import Tabs from './pages/tabs/Tabs'; import TabsSecondary from './pages/tabs/TabsSecondary'; import TabHistoryIsolation from './pages/tab-history-isolation/TabHistoryIsolation'; import Overlays from './pages/overlays/Overlays'; +import NestedTabsRelativeLinks from './pages/nested-tabs-relative-links/NestedTabsRelativeLinks'; +import RootSplatTabs from './pages/root-splat-tabs/RootSplatTabs'; +import ContentChangeNavigation from './pages/content-change-navigation/ContentChangeNavigation'; setupIonicReact(); @@ -72,6 +76,11 @@ const App: React.FC = () => { } /> } /> } /> + {/* Test root-level relative path - no leading slash */} + } /> + } /> + } /> + } /> diff --git a/packages/react-router/test/base/src/pages/Main.tsx b/packages/react-router/test/base/src/pages/Main.tsx index 4f87061e347..1a8ed4f1074 100644 --- a/packages/react-router/test/base/src/pages/Main.tsx +++ b/packages/react-router/test/base/src/pages/Main.tsx @@ -77,6 +77,18 @@ const Main: React.FC = () => { Nested Params + + Relative Paths + + + Nested Tabs Relative Links + + + Root Splat Tabs + + + Content Change Navigation + diff --git a/packages/react-router/test/base/src/pages/content-change-navigation/ContentChangeNavigation.tsx b/packages/react-router/test/base/src/pages/content-change-navigation/ContentChangeNavigation.tsx new file mode 100644 index 00000000000..16075d9ab4e --- /dev/null +++ b/packages/react-router/test/base/src/pages/content-change-navigation/ContentChangeNavigation.tsx @@ -0,0 +1,109 @@ +/** + * Reproduces the bug where changing view content while navigating causes + * an invalid view to be rendered. + */ + +import { + IonContent, + IonHeader, + IonPage, + IonTitle, + IonToolbar, + IonList, + IonItem, + IonLabel, + IonButton, + IonRouterOutlet, + IonButtons, + IonBackButton, +} from '@ionic/react'; +import React, { useState } from 'react'; +import { Route, Navigate, useNavigate } from 'react-router-dom'; + +const ListPage: React.FC = () => { + const [items, setItems] = useState(['Item 1', 'Item 2', 'Item 3']); + const navigate = useNavigate(); + + const clearItemsAndNavigate = () => { + setItems([]); + navigate('/content-change-navigation/home'); + }; + + // Using different keys forces React to unmount/remount IonPage + if (items.length === 0) { + return ( + + + + + + + Empty List + + + +
There are no items
+ + Go Home + +
+
+ ); + } + + return ( + + + + + + + Item List + + + + + {items.map((item, index) => ( + + {item} + + ))} + +
+ + Remove all items and navigate to home + +
+
+ ); +}; + +const HomePage: React.FC = () => { + return ( + + + + Home + + + +
Home Page Content
+ + Go to list page + +
+
+ ); +}; + +const ContentChangeNavigation: React.FC = () => { + return ( + + } /> + } /> + } /> + + ); +}; + +export default ContentChangeNavigation; diff --git a/packages/react-router/test/base/src/pages/nested-tabs-relative-links/NestedTabsRelativeLinks.tsx b/packages/react-router/test/base/src/pages/nested-tabs-relative-links/NestedTabsRelativeLinks.tsx new file mode 100644 index 00000000000..8b51f82d39f --- /dev/null +++ b/packages/react-router/test/base/src/pages/nested-tabs-relative-links/NestedTabsRelativeLinks.tsx @@ -0,0 +1,194 @@ +import { + IonContent, + IonHeader, + IonPage, + IonTitle, + IonToolbar, + IonRouterOutlet, + IonTabs, + IonTabBar, + IonTabButton, + IonIcon, + IonLabel, + IonBackButton, + IonButtons, +} from '@ionic/react'; +import { triangle, ellipse, square } from 'ionicons/icons'; +import React from 'react'; +import { Link, Navigate, Route } from 'react-router-dom'; + +/** + * This test page verifies that relative links work correctly within + * nested IonRouterOutlet components, specifically in a tabs-based layout. + * + * Issue: When using React Router's inside the tab1 route + * with nested outlets and index routes, the relative path resolution can produce + * incorrect URLs (e.g., /tab1/tab1/page-a instead of /tab1/page-a). + * + * This test also verifies that absolute links work when a catch-all route + * is present. + */ + +// Tab content with relative links for testing +const Tab1Content: React.FC = () => { + return ( + + + + Tab 1 + + + +
+

Tab 1 - Home Page

+ {/* Relative link - should navigate to /nested-tabs-relative-links/tab1/page-a */} + + Go to Page A (relative) + +
+ {/* Absolute link - should also work */} + + Go to Page A (absolute) + +
+ {/* Another relative link */} + + Go to Page B (relative) + +
+
+
+ ); +}; + +const PageA: React.FC = () => { + return ( + + + + + + + Page A + + + +
+ This is Page A within Tab 1 +
+
+
+ ); +}; + +const PageB: React.FC = () => { + return ( + + + + + + + Page B + + + +
+ This is Page B within Tab 1 +
+
+
+ ); +}; + +// Nested router outlet for Tab 1 - similar to user's RouterOutletTab1 +const Tab1RouterOutlet: React.FC = () => { + return ( + + } /> + } /> + } /> + + ); +}; + +const Tab2Content: React.FC = () => { + return ( + + + + Tab 2 + + + +
+ Tab 2 Content +
+
+
+ ); +}; + +const Tab3Content: React.FC = () => { + return ( + + + + Tab 3 + + + +
+ Tab 3 Content +
+
+
+ ); +}; + +// Main tabs component - wraps tabs with catch-all route (similar to user's reproduction) +const TabsContainer: React.FC = () => ( + + + {/* Tab 1 has nested routes with index route */} + } /> + } /> + } /> + } /> + {/* Catch-all 404 route - this presence caused issues with absolute links */} + + +

404 - Not Found

+
+ + } + /> +
+ + + + Tab 1 + + + + Tab 2 + + + + Tab 3 + + +
+); + +// Top-level component - splat route renders tabs +const NestedTabsRelativeLinks: React.FC = () => ( + + } /> + +); + +export default NestedTabsRelativeLinks; diff --git a/packages/react-router/test/base/src/pages/relative-paths/RelativePaths.tsx b/packages/react-router/test/base/src/pages/relative-paths/RelativePaths.tsx new file mode 100644 index 00000000000..73d2fe8f496 --- /dev/null +++ b/packages/react-router/test/base/src/pages/relative-paths/RelativePaths.tsx @@ -0,0 +1,103 @@ +import { + IonContent, + IonHeader, + IonPage, + IonTitle, + IonToolbar, + IonRouterOutlet, + IonList, + IonItem, + IonLabel, + IonBackButton, + IonButtons, +} from '@ionic/react'; +import React from 'react'; +import { Route } from 'react-router-dom'; + +/** + * This test page verifies that IonRouterOutlet correctly handles + * relative paths (paths without a leading slash) the same way + * React Router 6's Routes component does. + */ + +const RelativePathsHome: React.FC = () => { + return ( + + + + + + + Relative Paths Test + + + + + + Go to Page A (absolute path route) + + + Go to Page B (relative path route) + + + + + ); +}; + +const PageA: React.FC = () => { + return ( + + + + + + + Page A + + + +
+ This is Page A - route defined with absolute path +
+
+
+ ); +}; + +const PageB: React.FC = () => { + return ( + + + + + + + Page B + + + +
+ This is Page B - route defined with relative path (no leading slash) +
+
+
+ ); +}; + +const RelativePaths: React.FC = () => { + return ( + + {/* Route with absolute path (has leading slash) - this should work */} + } /> + + {/* Route with relative path (no leading slash) */} + } /> + + {/* Home route - using relative path */} + } /> + + ); +}; + +export default RelativePaths; diff --git a/packages/react-router/test/base/src/pages/root-splat-tabs/RootSplatTabs.tsx b/packages/react-router/test/base/src/pages/root-splat-tabs/RootSplatTabs.tsx new file mode 100644 index 00000000000..f967ac96ffd --- /dev/null +++ b/packages/react-router/test/base/src/pages/root-splat-tabs/RootSplatTabs.tsx @@ -0,0 +1,163 @@ +import { + IonContent, + IonHeader, + IonPage, + IonTitle, + IonToolbar, + IonRouterOutlet, + IonTabs, + IonTabBar, + IonTabButton, + IonIcon, + IonLabel, + IonBackButton, + IonButtons, +} from '@ionic/react'; +import { triangle, ellipse, square } from 'ionicons/icons'; +import React from 'react'; +import { Link, Navigate, Route } from 'react-router-dom'; + +/** + * Test page for root-level splat routes with relative tab paths. + * + * Structure: Outer splat route "*" renders IonTabs, with relative paths + * like "tab1/*" (no leading slash) inside the tabs outlet. + * + * This tests the fix for routes with relative paths inside root-level splat routes. + */ + +// Tab content with relative links for testing +const Tab1Content: React.FC = () => { + return ( + + + + Tab 1 + + + +
+

Tab 1 - Home Page (Root Splat Test)

+ + Go to Page A (relative) + +
+ + Go to Page A (absolute) + +
+
+
+ ); +}; + +const PageA: React.FC = () => { + return ( + + + + + + + Page A + + + +
+ This is Page A within Tab 1 (Root Splat Test) +
+
+
+ ); +}; + +// Nested router outlet for Tab 1 - matches customer's RouterOutletTab1 +const Tab1RouterOutlet: React.FC = () => { + return ( + + + } /> + } /> + + + ); +}; + +const Tab2Content: React.FC = () => { + return ( + + + + Tab 2 + + + +
+ Tab 2 Content (Root Splat Test) +
+
+
+ ); +}; + +const Tab3Content: React.FC = () => { + return ( + + + + Tab 3 + + + +
+ Tab 3 Content (Root Splat Test) +
+
+
+ ); +}; + +const NotFoundPage: React.FC = () => { + return ( + + +

404 - Not Found (Root Splat Test)

+
+
+ ); +}; + +// Tabs rendered directly inside a catch-all splat route +const TabsWithSplatRoutes: React.FC = () => { + return ( + + + {/* Using RELATIVE path "tab1/*" (no leading slash) - the key test case */} + } /> + } /> + } /> + } /> + } /> + + + + + Tab 1 + + + + Tab 2 + + + + Tab 3 + + + + ); +}; + +// Main component - renders tabs directly (no outlet wrapper) +const RootSplatTabs: React.FC = () => ; + +export default RootSplatTabs; diff --git a/packages/react-router/test/base/tests/e2e/specs/content-change-navigation.cy.js b/packages/react-router/test/base/tests/e2e/specs/content-change-navigation.cy.js new file mode 100644 index 00000000000..a812b042b43 --- /dev/null +++ b/packages/react-router/test/base/tests/e2e/specs/content-change-navigation.cy.js @@ -0,0 +1,54 @@ +/** + * Verifies that when view content changes (causing IonPage to remount) + * while navigation is happening, the correct view is displayed. + * + * @see https://github.com/ionic-team/ionic-framework/issues/28878 + */ + +const port = 3000; + +describe('Content Change Navigation Tests', () => { + it('should navigate to list page correctly', () => { + cy.visit(`http://localhost:${port}/content-change-navigation`); + cy.ionPageVisible('content-nav-home'); + + cy.get('[data-testid="go-to-list"]').click(); + cy.wait(300); + + cy.ionPageVisible('list-page'); + cy.url().should('include', '/content-change-navigation/list'); + }); + + it('when clearing items and navigating, should show home page, not empty view', () => { + cy.visit(`http://localhost:${port}/content-change-navigation`); + cy.ionPageVisible('content-nav-home'); + + cy.get('[data-testid="go-to-list"]').click(); + cy.wait(300); + cy.ionPageVisible('list-page'); + + // Bug scenario: clearing items renders a different IonPage while navigating away + cy.get('[data-testid="clear-and-navigate"]').click(); + cy.wait(500); + + cy.url().should('include', '/content-change-navigation/home'); + cy.url().should('not.include', '/content-change-navigation/list'); + cy.ionPageVisible('content-nav-home'); + cy.get('[data-testid="home-content"]').should('be.visible'); + + // The empty view should NOT be visible (the fix ensures it's hidden) + cy.get('[data-testid="empty-view"]').should('not.be.visible'); + }); + + it('direct navigation to home should work correctly', () => { + cy.visit(`http://localhost:${port}/content-change-navigation/home`); + cy.ionPageVisible('content-nav-home'); + cy.get('[data-testid="home-content"]').should('be.visible'); + }); + + it('direct navigation to list should work correctly', () => { + cy.visit(`http://localhost:${port}/content-change-navigation/list`); + cy.ionPageVisible('list-page'); + cy.contains('Item 1').should('be.visible'); + }); +}); diff --git a/packages/react-router/test/base/tests/e2e/specs/nested-tabs-relative-links.cy.js b/packages/react-router/test/base/tests/e2e/specs/nested-tabs-relative-links.cy.js new file mode 100644 index 00000000000..dc375e1c83f --- /dev/null +++ b/packages/react-router/test/base/tests/e2e/specs/nested-tabs-relative-links.cy.js @@ -0,0 +1,119 @@ +const port = 3000; + +/** + * Tests for relative links within nested IonRouterOutlet components. + * + * This specifically tests the scenario where: + * 1. IonRouterOutlet has a catch-all route (*) containing IonTabs + * 2. Inside tabs, there's another outlet with nested routes using index routes + * 3. React Router's is used for navigation + * + * The expected behavior is: + * - at /nested-tabs-relative-links/tab1 should produce + * href="/nested-tabs-relative-links/tab1/page-a" (not /tab1/tab1/page-a) + * - should work and not 404 + */ +describe('Nested Tabs with Relative Links', () => { + it('should navigate to tab1 by default', () => { + cy.visit(`http://localhost:${port}/nested-tabs-relative-links`); + cy.ionPageVisible('nested-tabs-relative-tab1'); + cy.get('[data-testid="tab1-content"]').should('exist'); + }); + + it('should have correct href for relative link', () => { + cy.visit(`http://localhost:${port}/nested-tabs-relative-links/tab1`); + cy.ionPageVisible('nested-tabs-relative-tab1'); + + // Check that the relative link has the correct href + // It should be /nested-tabs-relative-links/tab1/page-a, NOT /tab1/tab1/page-a + cy.get('[data-testid="link-relative-page-a"]') + .should('have.attr', 'href', '/nested-tabs-relative-links/tab1/page-a'); + }); + + it('should navigate to Page A via relative link', () => { + cy.visit(`http://localhost:${port}/nested-tabs-relative-links/tab1`); + cy.ionPageVisible('nested-tabs-relative-tab1'); + + // Click the relative link + cy.get('[data-testid="link-relative-page-a"]').click(); + + // Should be at Page A + cy.ionPageVisible('nested-tabs-relative-page-a'); + cy.get('[data-testid="page-a-content"]').should('exist'); + + // URL should be correct + cy.url().should('include', '/nested-tabs-relative-links/tab1/page-a'); + // URL should NOT have duplicate path segments + cy.url().should('not.include', '/tab1/tab1/'); + }); + + it('should navigate to Page A via absolute link', () => { + cy.visit(`http://localhost:${port}/nested-tabs-relative-links/tab1`); + cy.ionPageVisible('nested-tabs-relative-tab1'); + + // Click the absolute link + cy.get('[data-testid="link-absolute-page-a"]').click(); + + // Should be at Page A (not 404) + cy.ionPageVisible('nested-tabs-relative-page-a'); + cy.get('[data-testid="page-a-content"]').should('exist'); + + // Should NOT show 404 + cy.get('[data-testid="not-found"]').should('not.exist'); + }); + + it('should navigate to Page B via relative link', () => { + cy.visit(`http://localhost:${port}/nested-tabs-relative-links/tab1`); + cy.ionPageVisible('nested-tabs-relative-tab1'); + + // Click the relative link to page B + cy.get('[data-testid="link-relative-page-b"]').click(); + + // Should be at Page B + cy.ionPageVisible('nested-tabs-relative-page-b'); + cy.get('[data-testid="page-b-content"]').should('exist'); + + // URL should be correct + cy.url().should('include', '/nested-tabs-relative-links/tab1/page-b'); + }); + + it('should navigate to Page A and back', () => { + cy.visit(`http://localhost:${port}/nested-tabs-relative-links/tab1`); + cy.ionPageVisible('nested-tabs-relative-tab1'); + + // Navigate to Page A + cy.get('[data-testid="link-relative-page-a"]').click(); + cy.ionPageVisible('nested-tabs-relative-page-a'); + + // Go back + cy.ionBackClick('nested-tabs-relative-page-a'); + + // Should be back at Tab 1 + cy.ionPageVisible('nested-tabs-relative-tab1'); + }); + + it('should directly visit Page A via URL', () => { + cy.visit(`http://localhost:${port}/nested-tabs-relative-links/tab1/page-a`); + + // Should be at Page A (not 404) + cy.ionPageVisible('nested-tabs-relative-page-a'); + cy.get('[data-testid="page-a-content"]').should('exist'); + }); + + it('should switch tabs and maintain correct relative link resolution', () => { + cy.visit(`http://localhost:${port}/nested-tabs-relative-links/tab1`); + cy.ionPageVisible('nested-tabs-relative-tab1'); + + // Switch to Tab 2 + cy.ionTabClick('Tab 2'); + cy.ionPageVisible('nested-tabs-relative-tab2'); + + // Switch back to Tab 1 + cy.ionTabClick('Tab 1'); + cy.ionPageVisible('nested-tabs-relative-tab1'); + + // The relative link should still have correct href + cy.get('[data-testid="link-relative-page-a"]') + .should('have.attr', 'href', '/nested-tabs-relative-links/tab1/page-a'); + }); +}); diff --git a/packages/react-router/test/base/tests/e2e/specs/relative-paths.cy.js b/packages/react-router/test/base/tests/e2e/specs/relative-paths.cy.js new file mode 100644 index 00000000000..b88e061c592 --- /dev/null +++ b/packages/react-router/test/base/tests/e2e/specs/relative-paths.cy.js @@ -0,0 +1,44 @@ +const port = 3000; + +/** + * Tests for relative path handling in IonRouterOutlet. + * Verifies that routes with relative paths (no leading slash) work + * the same as absolute paths, matching React Router 6 behavior. + */ +describe('Relative Paths Tests', () => { + it('should navigate to the relative paths home page', () => { + cy.visit(`http://localhost:${port}/relative-paths`); + cy.ionPageVisible('relative-paths-home'); + }); + + it('should navigate to Page A (defined with absolute path)', () => { + cy.visit(`http://localhost:${port}/relative-paths`); + cy.ionPageVisible('relative-paths-home'); + cy.ionNav('ion-item', 'Go to Page A'); + cy.ionPageVisible('relative-paths-page-a'); + cy.get('[data-testid="page-a-content"]').should('contain', 'Page A'); + }); + + it('should navigate to Page B (defined with relative path - no leading slash)', () => { + cy.visit(`http://localhost:${port}/relative-paths`); + cy.ionPageVisible('relative-paths-home'); + cy.ionNav('ion-item', 'Go to Page B'); + cy.ionPageVisible('relative-paths-page-b'); + cy.get('[data-testid="page-b-content"]').should('contain', 'Page B'); + }); + + it('should navigate directly to Page B via URL', () => { + cy.visit(`http://localhost:${port}/relative-paths/page-b`); + cy.ionPageVisible('relative-paths-page-b'); + cy.get('[data-testid="page-b-content"]').should('contain', 'Page B'); + }); + + it('should navigate to Page B and back', () => { + cy.visit(`http://localhost:${port}/relative-paths`); + cy.ionPageVisible('relative-paths-home'); + cy.ionNav('ion-item', 'Go to Page B'); + cy.ionPageVisible('relative-paths-page-b'); + cy.ionBackClick('relative-paths-page-b'); + cy.ionPageVisible('relative-paths-home'); + }); +}); diff --git a/packages/react-router/test/base/tests/e2e/specs/root-splat-tabs.cy.js b/packages/react-router/test/base/tests/e2e/specs/root-splat-tabs.cy.js new file mode 100644 index 00000000000..e9f626da4b6 --- /dev/null +++ b/packages/react-router/test/base/tests/e2e/specs/root-splat-tabs.cy.js @@ -0,0 +1,95 @@ +const port = 3000; + +/** + * Tests for relative paths (e.g., "tab1/*") inside root-level splat routes (*). + * Verifies the fix for routes not matching when parent is a splat-only route. + */ +describe('Root Splat Tabs - Customer Reproduction', () => { + it('should navigate to tab1 by default when visiting /root-splat-tabs', () => { + cy.visit(`http://localhost:${port}/root-splat-tabs`); + // Should redirect to tab1 and show tab1 content + cy.ionPageVisible('root-splat-tab1'); + cy.get('[data-testid="root-splat-tab1-content"]').should('exist'); + }); + + it('should load tab1 when directly visiting /root-splat-tabs/tab1', () => { + cy.visit(`http://localhost:${port}/root-splat-tabs/tab1`); + // CRITICAL: This should show tab1 content, NOT 404 + cy.ionPageVisible('root-splat-tab1'); + cy.get('[data-testid="root-splat-tab1-content"]').should('exist'); + cy.get('[data-testid="root-splat-not-found"]').should('not.exist'); + }); + + it('should load Page A when directly visiting /root-splat-tabs/tab1/page-a', () => { + cy.visit(`http://localhost:${port}/root-splat-tabs/tab1/page-a`); + // CRITICAL: This should show Page A, NOT 404 + // This is the exact issue the customer reported + cy.ionPageVisible('root-splat-page-a'); + cy.get('[data-testid="root-splat-page-a-content"]').should('exist'); + cy.get('[data-testid="root-splat-not-found"]').should('not.exist'); + }); + + it('should navigate to Page A via relative link', () => { + cy.visit(`http://localhost:${port}/root-splat-tabs/tab1`); + cy.ionPageVisible('root-splat-tab1'); + + // Click the relative link + cy.get('[data-testid="link-relative-page-a"]').click(); + + // Should be at Page A (not 404) + cy.ionPageVisible('root-splat-page-a'); + cy.get('[data-testid="root-splat-page-a-content"]').should('exist'); + cy.get('[data-testid="root-splat-not-found"]').should('not.exist'); + + // URL should be correct + cy.url().should('include', '/root-splat-tabs/tab1/page-a'); + }); + + it('should navigate to Page A via absolute link', () => { + cy.visit(`http://localhost:${port}/root-splat-tabs/tab1`); + cy.ionPageVisible('root-splat-tab1'); + + // Click the absolute link + cy.get('[data-testid="link-absolute-page-a"]').click(); + + // Should be at Page A (not 404) + cy.ionPageVisible('root-splat-page-a'); + cy.get('[data-testid="root-splat-page-a-content"]').should('exist'); + cy.get('[data-testid="root-splat-not-found"]').should('not.exist'); + }); + + it('should have correct href for relative link', () => { + cy.visit(`http://localhost:${port}/root-splat-tabs/tab1`); + cy.ionPageVisible('root-splat-tab1'); + + // The relative link should resolve to the correct absolute href + cy.get('[data-testid="link-relative-page-a"]') + .should('have.attr', 'href', '/root-splat-tabs/tab1/page-a'); + }); + + it('should navigate between tabs correctly', () => { + cy.visit(`http://localhost:${port}/root-splat-tabs/tab1`); + cy.ionPageVisible('root-splat-tab1'); + + // Switch to Tab 2 + cy.ionTabClick('Tab 2'); + cy.ionPageVisible('root-splat-tab2'); + + // Switch back to Tab 1 + cy.ionTabClick('Tab 1'); + cy.ionPageVisible('root-splat-tab1'); + }); + + it('should navigate to Page A and back to Tab 1', () => { + cy.visit(`http://localhost:${port}/root-splat-tabs/tab1`); + cy.ionPageVisible('root-splat-tab1'); + + // Navigate to Page A + cy.get('[data-testid="link-relative-page-a"]').click(); + cy.ionPageVisible('root-splat-page-a'); + + // Go back + cy.ionBackClick('root-splat-page-a'); + cy.ionPageVisible('root-splat-tab1'); + }); +});