diff --git a/packages/react-router/src/ReactRouter/ReactRouterViewStack.tsx b/packages/react-router/src/ReactRouter/ReactRouterViewStack.tsx index 399f7f133c..ea4995625f 100644 --- a/packages/react-router/src/ReactRouter/ReactRouterViewStack.tsx +++ b/packages/react-router/src/ReactRouter/ReactRouterViewStack.tsx @@ -331,9 +331,9 @@ export class ReactRouterViewStack extends ViewStacks { viewItem.routeData.match = match; } - // Deactivate wildcard routes when we have specific route matches - // This prevents "Not found" from showing alongside valid routes - if (routePath === '*') { + // Deactivate wildcard routes and catch-all routes (empty path) when we have specific route matches + // This prevents "Not found" or fallback pages from showing alongside valid routes + if (routePath === '*' || routePath === '') { // Check if any other view in this outlet has a match for the current route const hasSpecificMatch = this.getViewItemsForOutlet(viewItem.outletId).some((v) => { if (v.id === viewItem.id) return false; // Skip self @@ -349,6 +349,11 @@ export class ReactRouterViewStack extends ViewStacks { if (hasSpecificMatch) { viewItem.mount = false; + // Also hide the ion-page element immediately to prevent visual overlap + if (viewItem.ionPageElement) { + viewItem.ionPageElement.classList.add('ion-page-hidden'); + viewItem.ionPageElement.setAttribute('aria-hidden', 'true'); + } } } diff --git a/packages/react-router/src/ReactRouter/StackManager.tsx b/packages/react-router/src/ReactRouter/StackManager.tsx index ea5c3590ee..d685004efe 100644 --- a/packages/react-router/src/ReactRouter/StackManager.tsx +++ b/packages/react-router/src/ReactRouter/StackManager.tsx @@ -46,6 +46,44 @@ interface StackManagerState {} const isViewVisible = (el: HTMLElement) => !el.classList.contains('ion-page-invisible') && !el.classList.contains('ion-page-hidden'); +/** + * Finds the longest common prefix among an array of paths. + * Used to determine the scope of an outlet with absolute routes. + */ +const computeCommonPrefix = (paths: string[]): string => { + if (paths.length === 0) return ''; + if (paths.length === 1) { + // For a single path, extract the directory-like prefix + // e.g., /dynamic-routes/home -> /dynamic-routes + const segments = paths[0].split('/').filter(Boolean); + if (segments.length > 1) { + return '/' + segments.slice(0, -1).join('/'); + } + return '/' + segments[0]; + } + + // Split all paths into segments + const segmentArrays = paths.map((p) => p.split('/').filter(Boolean)); + const minLength = Math.min(...segmentArrays.map((s) => s.length)); + + const commonSegments: string[] = []; + for (let i = 0; i < minLength; i++) { + const segment = segmentArrays[0][i]; + // Skip segments with route parameters or wildcards + if (segment.includes(':') || segment.includes('*')) { + break; + } + const allMatch = segmentArrays.every((s) => s[i] === segment); + if (allMatch) { + commonSegments.push(segment); + } else { + break; + } + } + + return commonSegments.length > 0 ? '/' + commonSegments.join('/') : ''; +}; + export class StackManager extends React.PureComponent { id: string; // Unique id for the router outlet aka outletId context!: React.ContextType; @@ -216,6 +254,34 @@ export class StackManager extends React.PureComponent { + const path = route.props.path; + return path && path.startsWith('/'); + }); + + if (absolutePathRoutes.length > 0) { + const absolutePaths = absolutePathRoutes.map((r) => r.props.path as string); + const commonPrefix = computeCommonPrefix(absolutePaths); + + if (commonPrefix && commonPrefix !== '/') { + // Set the mount path based on common prefix of absolute routes + if (!this.outletMountPath) { + this.outletMountPath = commonPrefix; + } + + // Check if current pathname is within scope + if (!currentPathname.startsWith(commonPrefix)) { + return undefined; + } + + return commonPrefix; + } + } + } } return this.outletMountPath; } @@ -262,6 +328,22 @@ export class StackManager extends React.PureComponent { + if (viewItem.ionPageElement) { + viewItem.ionPageElement.classList.add('ion-page-hidden'); + viewItem.ionPageElement.setAttribute('aria-hidden', 'true'); + } + }); + this.clearOutletTimeout = this.context.clearOutlet(this.id); } @@ -1121,16 +1203,75 @@ function findRouteByRouteInfo(node: React.ReactNode, routeInfo: RouteInfo, paren } // If we haven't found a node, try to find one that doesn't have a path prop (fallback route) - for (const child of routeChildren) { - if (!child.props.path) { - fallbackNode = child; - break; + // BUT only return the fallback if the current pathname is within the outlet's scope. + // For outlets with absolute paths, compute the common prefix to determine scope. + const absolutePathRoutes = routeChildren.filter((r) => r.props.path && r.props.path.startsWith('/')); + + // Determine if pathname is within scope before returning fallback + let isPathnameInScope = true; + + if (absolutePathRoutes.length > 0) { + // Find common prefix of all absolute paths to determine outlet scope + const absolutePaths = absolutePathRoutes.map((r) => r.props.path as string); + const commonPrefix = findCommonPrefix(absolutePaths); + + // If we have a common prefix, check if the current pathname is within that scope + if (commonPrefix && commonPrefix !== '/') { + isPathnameInScope = routeInfo.pathname.startsWith(commonPrefix); + } + } + + // Only look for fallback route if pathname is within scope + if (isPathnameInScope) { + for (const child of routeChildren) { + if (!child.props.path) { + fallbackNode = child; + break; + } } } return matchedNode ?? fallbackNode; } +/** + * Finds the longest common prefix among an array of paths. + * Used to determine the scope of an outlet with absolute routes. + */ +function findCommonPrefix(paths: string[]): string { + if (paths.length === 0) return ''; + if (paths.length === 1) { + // For a single path, extract the directory-like prefix + // e.g., /dynamic-routes/home -> /dynamic-routes + const segments = paths[0].split('/').filter(Boolean); + if (segments.length > 1) { + return '/' + segments.slice(0, -1).join('/'); + } + return '/' + segments[0]; + } + + // Split all paths into segments + const segmentArrays = paths.map((p) => p.split('/').filter(Boolean)); + const minLength = Math.min(...segmentArrays.map((s) => s.length)); + + const commonSegments: string[] = []; + for (let i = 0; i < minLength; i++) { + const segment = segmentArrays[0][i]; + // Skip segments with route parameters or wildcards + if (segment.includes(':') || segment.includes('*')) { + break; + } + const allMatch = segmentArrays.every((s) => s[i] === segment); + if (allMatch) { + commonSegments.push(segment); + } else { + break; + } + } + + return commonSegments.length > 0 ? '/' + commonSegments.join('/') : ''; +} + function matchComponent(node: React.ReactElement, pathname: string, forceExact?: boolean) { const routePath: string | undefined = node?.props?.path; const pathnameToMatch = derivePathnameToMatch(pathname, routePath);