Skip to content

Commit 3912623

Browse files
committed
fix(react-router): hide deactivated catch-all routes
1 parent 8fbd2d4 commit 3912623

File tree

2 files changed

+153
-7
lines changed

2 files changed

+153
-7
lines changed

packages/react-router/src/ReactRouter/ReactRouterViewStack.tsx

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -331,9 +331,9 @@ export class ReactRouterViewStack extends ViewStacks {
331331
viewItem.routeData.match = match;
332332
}
333333

334-
// Deactivate wildcard routes when we have specific route matches
335-
// This prevents "Not found" from showing alongside valid routes
336-
if (routePath === '*') {
334+
// Deactivate wildcard routes and catch-all routes (empty path) when we have specific route matches
335+
// This prevents "Not found" or fallback pages from showing alongside valid routes
336+
if (routePath === '*' || routePath === '') {
337337
// Check if any other view in this outlet has a match for the current route
338338
const hasSpecificMatch = this.getViewItemsForOutlet(viewItem.outletId).some((v) => {
339339
if (v.id === viewItem.id) return false; // Skip self
@@ -349,6 +349,11 @@ export class ReactRouterViewStack extends ViewStacks {
349349

350350
if (hasSpecificMatch) {
351351
viewItem.mount = false;
352+
// Also hide the ion-page element immediately to prevent visual overlap
353+
if (viewItem.ionPageElement) {
354+
viewItem.ionPageElement.classList.add('ion-page-hidden');
355+
viewItem.ionPageElement.setAttribute('aria-hidden', 'true');
356+
}
352357
}
353358
}
354359

packages/react-router/src/ReactRouter/StackManager.tsx

Lines changed: 145 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,44 @@ interface StackManagerState {}
4646
const isViewVisible = (el: HTMLElement) =>
4747
!el.classList.contains('ion-page-invisible') && !el.classList.contains('ion-page-hidden');
4848

49+
/**
50+
* Finds the longest common prefix among an array of paths.
51+
* Used to determine the scope of an outlet with absolute routes.
52+
*/
53+
const computeCommonPrefix = (paths: string[]): string => {
54+
if (paths.length === 0) return '';
55+
if (paths.length === 1) {
56+
// For a single path, extract the directory-like prefix
57+
// e.g., /dynamic-routes/home -> /dynamic-routes
58+
const segments = paths[0].split('/').filter(Boolean);
59+
if (segments.length > 1) {
60+
return '/' + segments.slice(0, -1).join('/');
61+
}
62+
return '/' + segments[0];
63+
}
64+
65+
// Split all paths into segments
66+
const segmentArrays = paths.map((p) => p.split('/').filter(Boolean));
67+
const minLength = Math.min(...segmentArrays.map((s) => s.length));
68+
69+
const commonSegments: string[] = [];
70+
for (let i = 0; i < minLength; i++) {
71+
const segment = segmentArrays[0][i];
72+
// Skip segments with route parameters or wildcards
73+
if (segment.includes(':') || segment.includes('*')) {
74+
break;
75+
}
76+
const allMatch = segmentArrays.every((s) => s[i] === segment);
77+
if (allMatch) {
78+
commonSegments.push(segment);
79+
} else {
80+
break;
81+
}
82+
}
83+
84+
return commonSegments.length > 0 ? '/' + commonSegments.join('/') : '';
85+
};
86+
4987
export class StackManager extends React.PureComponent<StackManagerProps, StackManagerState> {
5088
id: string; // Unique id for the router outlet aka outletId
5189
context!: React.ContextType<typeof RouteManagerContext>;
@@ -216,6 +254,34 @@ export class StackManager extends React.PureComponent<StackManagerProps, StackMa
216254
return bestPath;
217255
}
218256
}
257+
258+
// Handle outlets with ONLY absolute routes (no relative routes or index routes)
259+
// Compute the common prefix of all absolute routes to determine the outlet's scope
260+
if (!hasRelativeRoutes && !hasIndexRoute) {
261+
const absolutePathRoutes = routeChildren.filter((route) => {
262+
const path = route.props.path;
263+
return path && path.startsWith('/');
264+
});
265+
266+
if (absolutePathRoutes.length > 0) {
267+
const absolutePaths = absolutePathRoutes.map((r) => r.props.path as string);
268+
const commonPrefix = computeCommonPrefix(absolutePaths);
269+
270+
if (commonPrefix && commonPrefix !== '/') {
271+
// Set the mount path based on common prefix of absolute routes
272+
if (!this.outletMountPath) {
273+
this.outletMountPath = commonPrefix;
274+
}
275+
276+
// Check if current pathname is within scope
277+
if (!currentPathname.startsWith(commonPrefix)) {
278+
return undefined;
279+
}
280+
281+
return commonPrefix;
282+
}
283+
}
284+
}
219285
}
220286
return this.outletMountPath;
221287
}
@@ -262,6 +328,22 @@ export class StackManager extends React.PureComponent<StackManagerProps, StackMa
262328
this.outOfScopeUnmountTimeout = undefined;
263329
}
264330
this.waitingForIonPage = false;
331+
332+
// Hide all views in this outlet before clearing.
333+
// This is critical for nested outlets - when the parent component unmounts,
334+
// the nested outlet's componentDidUpdate won't be called, so we must hide
335+
// the ion-page elements here to prevent them from remaining visible on top
336+
// of other content after navigation to a different route.
337+
const allViewsInOutlet = this.context.getViewItemsForOutlet
338+
? this.context.getViewItemsForOutlet(this.id)
339+
: [];
340+
allViewsInOutlet.forEach((viewItem) => {
341+
if (viewItem.ionPageElement) {
342+
viewItem.ionPageElement.classList.add('ion-page-hidden');
343+
viewItem.ionPageElement.setAttribute('aria-hidden', 'true');
344+
}
345+
});
346+
265347
this.clearOutletTimeout = this.context.clearOutlet(this.id);
266348
}
267349

@@ -1121,16 +1203,75 @@ function findRouteByRouteInfo(node: React.ReactNode, routeInfo: RouteInfo, paren
11211203
}
11221204

11231205
// If we haven't found a node, try to find one that doesn't have a path prop (fallback route)
1124-
for (const child of routeChildren) {
1125-
if (!child.props.path) {
1126-
fallbackNode = child;
1127-
break;
1206+
// BUT only return the fallback if the current pathname is within the outlet's scope.
1207+
// For outlets with absolute paths, compute the common prefix to determine scope.
1208+
const absolutePathRoutes = routeChildren.filter((r) => r.props.path && r.props.path.startsWith('/'));
1209+
1210+
// Determine if pathname is within scope before returning fallback
1211+
let isPathnameInScope = true;
1212+
1213+
if (absolutePathRoutes.length > 0) {
1214+
// Find common prefix of all absolute paths to determine outlet scope
1215+
const absolutePaths = absolutePathRoutes.map((r) => r.props.path as string);
1216+
const commonPrefix = findCommonPrefix(absolutePaths);
1217+
1218+
// If we have a common prefix, check if the current pathname is within that scope
1219+
if (commonPrefix && commonPrefix !== '/') {
1220+
isPathnameInScope = routeInfo.pathname.startsWith(commonPrefix);
1221+
}
1222+
}
1223+
1224+
// Only look for fallback route if pathname is within scope
1225+
if (isPathnameInScope) {
1226+
for (const child of routeChildren) {
1227+
if (!child.props.path) {
1228+
fallbackNode = child;
1229+
break;
1230+
}
11281231
}
11291232
}
11301233

11311234
return matchedNode ?? fallbackNode;
11321235
}
11331236

1237+
/**
1238+
* Finds the longest common prefix among an array of paths.
1239+
* Used to determine the scope of an outlet with absolute routes.
1240+
*/
1241+
function findCommonPrefix(paths: string[]): string {
1242+
if (paths.length === 0) return '';
1243+
if (paths.length === 1) {
1244+
// For a single path, extract the directory-like prefix
1245+
// e.g., /dynamic-routes/home -> /dynamic-routes
1246+
const segments = paths[0].split('/').filter(Boolean);
1247+
if (segments.length > 1) {
1248+
return '/' + segments.slice(0, -1).join('/');
1249+
}
1250+
return '/' + segments[0];
1251+
}
1252+
1253+
// Split all paths into segments
1254+
const segmentArrays = paths.map((p) => p.split('/').filter(Boolean));
1255+
const minLength = Math.min(...segmentArrays.map((s) => s.length));
1256+
1257+
const commonSegments: string[] = [];
1258+
for (let i = 0; i < minLength; i++) {
1259+
const segment = segmentArrays[0][i];
1260+
// Skip segments with route parameters or wildcards
1261+
if (segment.includes(':') || segment.includes('*')) {
1262+
break;
1263+
}
1264+
const allMatch = segmentArrays.every((s) => s[i] === segment);
1265+
if (allMatch) {
1266+
commonSegments.push(segment);
1267+
} else {
1268+
break;
1269+
}
1270+
}
1271+
1272+
return commonSegments.length > 0 ? '/' + commonSegments.join('/') : '';
1273+
}
1274+
11341275
function matchComponent(node: React.ReactElement, pathname: string, forceExact?: boolean) {
11351276
const routePath: string | undefined = node?.props?.path;
11361277
const pathnameToMatch = derivePathnameToMatch(pathname, routePath);

0 commit comments

Comments
 (0)