@@ -46,6 +46,44 @@ interface StackManagerState {}
4646const 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+
4987export 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+
11341275function 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