@ionic/react-router 8.7.13-dev.11765907916.16a61ecf → 8.7.13-dev.11765921002.107104c2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -13,13 +13,10 @@ const IonRouteInner = ({ path, element }) => {
13
13
  * @see https://reactrouter.com/v6/utils/match-path
14
14
  */
15
15
  const matchPath = ({ pathname, componentProps }) => {
16
+ var _a, _b;
16
17
  const { path, index } = componentProps, restProps = __rest(componentProps, ["path", "index"]);
17
- // Handle index routes
18
+ // Handle index routes - they match when pathname is empty or just "/"
18
19
  if (index && !path) {
19
- // Index routes match when there's no additional path after the parent route
20
- // For example, in a nested outlet at /routing/*, the index route matches
21
- // when the relative path is empty (i.e., we're exactly at /routing)
22
- // If pathname is empty or just "/", it should match the index route
23
20
  if (pathname === '' || pathname === '/') {
24
21
  return {
25
22
  params: {},
@@ -32,14 +29,25 @@ const matchPath = ({ pathname, componentProps }) => {
32
29
  },
33
30
  };
34
31
  }
35
- // Otherwise, index routes don't match when there's additional path
36
32
  return null;
37
33
  }
38
- if (!path) {
34
+ // Handle empty path routes - they match when pathname is also empty or just "/"
35
+ if (path === '' || path === undefined) {
36
+ if (pathname === '' || pathname === '/') {
37
+ return {
38
+ params: {},
39
+ pathname: pathname,
40
+ pathnameBase: pathname || '/',
41
+ pattern: {
42
+ path: '',
43
+ caseSensitive: (_a = restProps.caseSensitive) !== null && _a !== void 0 ? _a : false,
44
+ end: (_b = restProps.end) !== null && _b !== void 0 ? _b : true,
45
+ },
46
+ };
47
+ }
39
48
  return null;
40
49
  }
41
- // For relative paths in nested routes (those that don't start with '/'),
42
- // use React Router's matcher against a normalized path.
50
+ // For relative paths (don't start with '/'), normalize both path and pathname for matching
43
51
  if (!path.startsWith('/')) {
44
52
  const matchOptions = Object.assign({ path: `/${path}` }, restProps);
45
53
  if ((matchOptions === null || matchOptions === void 0 ? void 0 : matchOptions.end) === undefined) {
@@ -51,7 +59,6 @@ const matchPath = ({ pathname, componentProps }) => {
51
59
  // Adjust the match to remove the leading '/' we added
52
60
  return Object.assign(Object.assign({}, match), { pathname: pathname, pathnameBase: match.pathnameBase === '/' ? '' : match.pathnameBase.slice(1), pattern: Object.assign(Object.assign({}, match.pattern), { path: path }) });
53
61
  }
54
- // No match found
55
62
  return null;
56
63
  }
57
64
  // For absolute paths, use React Router's matcher directly.
@@ -71,12 +78,16 @@ const matchPath = ({ pathname, componentProps }) => {
71
78
  */
72
79
  const derivePathnameToMatch = (fullPathname, routePath) => {
73
80
  var _a;
81
+ // For absolute or empty routes, use the full pathname as-is
74
82
  if (!routePath || routePath === '' || routePath.startsWith('/')) {
75
83
  return fullPathname;
76
84
  }
77
85
  const trimmedPath = fullPathname.startsWith('/') ? fullPathname.slice(1) : fullPathname;
78
86
  if (!trimmedPath) {
79
- return '';
87
+ // For root-level relative routes (pathname is "/" and routePath is relative),
88
+ // return the full pathname so matchPath can normalize both.
89
+ // This allows routes like <Route path="foo/*" .../> at root level to work correctly.
90
+ return fullPathname;
80
91
  }
81
92
  const fullSegments = trimmedPath.split('/').filter(Boolean);
82
93
  if (fullSegments.length === 0) {
@@ -152,24 +163,36 @@ const computeCommonPrefix = (paths) => {
152
163
  return commonSegments.length > 0 ? '/' + commonSegments.join('/') : '';
153
164
  };
154
165
  /**
155
- * Checks if a route is a specific match (not wildcard or index).
156
- *
157
- * @param route The route element to check.
158
- * @param remainingPath The remaining path to match against.
159
- * @returns True if the route specifically matches the remaining path.
166
+ * Checks if a route path is a "splat-only" route (just `*` or `/*`).
167
+ */
168
+ const isSplatOnlyRoute = (routePath) => {
169
+ return routePath === '*' || routePath === '/*';
170
+ };
171
+ /**
172
+ * Checks if a route has an embedded wildcard (e.g., "tab1/*" but not "*" or "/*").
173
+ */
174
+ const hasEmbeddedWildcard = (routePath) => {
175
+ return !!routePath && routePath.includes('*') && !isSplatOnlyRoute(routePath);
176
+ };
177
+ /**
178
+ * Checks if a route with an embedded wildcard matches a pathname.
179
+ */
180
+ const matchesEmbeddedWildcardRoute = (route, pathname) => {
181
+ const routePath = route.props.path;
182
+ if (!hasEmbeddedWildcard(routePath)) {
183
+ return false;
184
+ }
185
+ return !!matchPath({ pathname, componentProps: route.props });
186
+ };
187
+ /**
188
+ * Checks if a route is a specific match (not wildcard-only or index).
160
189
  */
161
190
  const isSpecificRouteMatch = (route, remainingPath) => {
162
191
  const routePath = route.props.path;
163
- const isWildcardOnly = routePath === '*' || routePath === '/*';
164
- const isIndex = route.props.index;
165
- // Skip wildcards and index routes
166
- if (isIndex || isWildcardOnly) {
192
+ if (route.props.index || isSplatOnlyRoute(routePath)) {
167
193
  return false;
168
194
  }
169
- return !!matchPath({
170
- pathname: remainingPath,
171
- componentProps: route.props,
172
- });
195
+ return !!matchPath({ pathname: remainingPath, componentProps: route.props });
173
196
  };
174
197
  /**
175
198
  * Analyzes route children to determine their characteristics.
@@ -214,11 +237,13 @@ const computeParentPath = (options) => {
214
237
  let firstSpecificMatch = undefined;
215
238
  let firstWildcardMatch = undefined;
216
239
  let indexMatchAtMount = undefined;
240
+ // Start at i = 1 (normal case: strip at least one segment for parent path)
217
241
  for (let i = 1; i <= segments.length; i++) {
218
242
  const parentPath = '/' + segments.slice(0, i).join('/');
219
243
  const remainingPath = segments.slice(i).join('/');
220
- // Check for specific (non-wildcard, non-index) route matches
221
- const hasSpecificMatch = routeChildren.some((route) => isSpecificRouteMatch(route, remainingPath));
244
+ // Check for specific route matches (non-wildcard-only, non-index)
245
+ // Also check routes with embedded wildcards (e.g., "tab1/*")
246
+ const hasSpecificMatch = routeChildren.some((route) => isSpecificRouteMatch(route, remainingPath) || matchesEmbeddedWildcardRoute(route, remainingPath));
222
247
  if (hasSpecificMatch && !firstSpecificMatch) {
223
248
  firstSpecificMatch = parentPath;
224
249
  // Found a specific match - this is our answer for non-index routes
@@ -265,6 +290,16 @@ const computeParentPath = (options) => {
265
290
  }
266
291
  }
267
292
  }
293
+ // Fallback: check at root level (i = 0) for embedded wildcard routes.
294
+ // This handles outlets inside root-level splat routes where routes like
295
+ // "tab1/*" need to match the full pathname.
296
+ if (!firstSpecificMatch) {
297
+ const fullRemainingPath = segments.join('/');
298
+ const hasRootLevelMatch = routeChildren.some((route) => matchesEmbeddedWildcardRoute(route, fullRemainingPath));
299
+ if (hasRootLevelMatch) {
300
+ firstSpecificMatch = '/';
301
+ }
302
+ }
268
303
  // Determine the best parent path:
269
304
  // 1. Specific match (routes like tabs/*, favorites) - highest priority
270
305
  // 2. Wildcard match (route path="*") - catches unmatched segments
@@ -740,27 +775,36 @@ class ReactRouterViewStack extends ViewStacks {
740
775
  const combinedParams = Object.assign(Object.assign({}, accumulatedParentParams), ((_c = routeMatch === null || routeMatch === void 0 ? void 0 : routeMatch.params) !== null && _c !== void 0 ? _c : {}));
741
776
  // For relative route paths, we need to compute an absolute pathnameBase
742
777
  // by combining the parent's pathnameBase with the matched portion
743
- let absolutePathnameBase = (routeMatch === null || routeMatch === void 0 ? void 0 : routeMatch.pathnameBase) || routeInfo.pathname;
744
778
  const routePath = routeElement.props.path;
745
779
  const isRelativePath = routePath && !routePath.startsWith('/');
746
780
  const isIndexRoute = !!routeElement.props.index;
747
- if (isRelativePath || isIndexRoute) {
748
- // Get the parent's pathnameBase to build the absolute path
749
- const parentPathnameBase = parentMatches.length > 0 ? parentMatches[parentMatches.length - 1].pathnameBase : '/';
750
- // For relative paths, the matchPath returns a relative pathnameBase
751
- // We need to make it absolute by prepending the parent's base
752
- if ((routeMatch === null || routeMatch === void 0 ? void 0 : routeMatch.pathnameBase) && isRelativePath) {
753
- // Strip leading slash if present in the relative match
754
- const relativeBase = routeMatch.pathnameBase.startsWith('/')
755
- ? routeMatch.pathnameBase.slice(1)
756
- : routeMatch.pathnameBase;
757
- absolutePathnameBase =
758
- parentPathnameBase === '/' ? `/${relativeBase}` : `${parentPathnameBase}/${relativeBase}`;
759
- }
760
- else if (isIndexRoute) {
761
- // Index routes should use the parent's base as their base
762
- absolutePathnameBase = parentPathnameBase;
763
- }
781
+ const isSplatOnlyRoute = routePath === '*' || routePath === '/*';
782
+ // Get parent's pathnameBase for relative path resolution
783
+ const parentPathnameBase = parentMatches.length > 0 ? parentMatches[parentMatches.length - 1].pathnameBase : '/';
784
+ // Start with the match's pathnameBase, falling back to routeInfo.pathname
785
+ // BUT: splat-only routes should use parent's base (v7_relativeSplatPath behavior)
786
+ let absolutePathnameBase;
787
+ if (isSplatOnlyRoute) {
788
+ // Splat routes should NOT contribute their matched portion to pathnameBase
789
+ // This aligns with React Router v7's v7_relativeSplatPath behavior
790
+ // Without this, relative links inside splat routes get double path segments
791
+ absolutePathnameBase = parentPathnameBase;
792
+ }
793
+ else if (isRelativePath && (routeMatch === null || routeMatch === void 0 ? void 0 : routeMatch.pathnameBase)) {
794
+ // For relative paths with a pathnameBase, combine with parent
795
+ const relativeBase = routeMatch.pathnameBase.startsWith('/')
796
+ ? routeMatch.pathnameBase.slice(1)
797
+ : routeMatch.pathnameBase;
798
+ absolutePathnameBase =
799
+ parentPathnameBase === '/' ? `/${relativeBase}` : `${parentPathnameBase}/${relativeBase}`;
800
+ }
801
+ else if (isIndexRoute) {
802
+ // Index routes should use the parent's base as their base
803
+ absolutePathnameBase = parentPathnameBase;
804
+ }
805
+ else {
806
+ // Default: use the match's pathnameBase or the current pathname
807
+ absolutePathnameBase = (routeMatch === null || routeMatch === void 0 ? void 0 : routeMatch.pathnameBase) || routeInfo.pathname;
764
808
  }
765
809
  const contextMatches = [
766
810
  ...parentMatches,
@@ -804,7 +848,9 @@ class ReactRouterViewStack extends ViewStacks {
804
848
  let parentPath = undefined;
805
849
  try {
806
850
  // Only attempt parent path computation for non-root outlets
807
- if (outletId !== 'routerOutlet') {
851
+ // Root outlets have IDs like 'routerOutlet' or 'routerOutlet-2'
852
+ const isRootOutlet = outletId.startsWith('routerOutlet');
853
+ if (!isRootOutlet) {
808
854
  const routeChildren = extractRouteChildren(ionRouterOutlet.props.children);
809
855
  const { hasRelativeRoutes, hasIndexRoute, hasWildcardRoute } = analyzeRouteChildren(routeChildren);
810
856
  if (hasRelativeRoutes || hasIndexRoute) {
@@ -1046,7 +1092,7 @@ class ReactRouterViewStack extends ViewStacks {
1046
1092
  * Matches a view with no path prop (default fallback route) or index route.
1047
1093
  */
1048
1094
  function matchDefaultRoute(v) {
1049
- var _a;
1095
+ var _a, _b, _c;
1050
1096
  const childProps = v.routeData.childProps;
1051
1097
  const isDefaultRoute = childProps.path === undefined || childProps.path === '';
1052
1098
  const isIndexRoute = !!childProps.index;
@@ -1059,14 +1105,22 @@ class ReactRouterViewStack extends ViewStacks {
1059
1105
  }
1060
1106
  return false;
1061
1107
  }
1108
+ // For empty path routes, only match if we're at the same level as when the view was created.
1109
+ // This prevents an empty path view item from being reused for different routes.
1062
1110
  if (isDefaultRoute) {
1111
+ const previousPathnameBase = ((_b = (_a = v.routeData) === null || _a === void 0 ? void 0 : _a.match) === null || _b === void 0 ? void 0 : _b.pathnameBase) || '';
1112
+ const normalizedBase = normalizePathnameForComparison(previousPathnameBase);
1113
+ const normalizedPathname = normalizePathnameForComparison(pathname);
1114
+ if (normalizedPathname !== normalizedBase) {
1115
+ return false;
1116
+ }
1063
1117
  match = {
1064
1118
  params: {},
1065
1119
  pathname,
1066
1120
  pathnameBase: pathname === '' ? '/' : pathname,
1067
1121
  pattern: {
1068
1122
  path: '',
1069
- caseSensitive: (_a = childProps.caseSensitive) !== null && _a !== void 0 ? _a : false,
1123
+ caseSensitive: (_c = childProps.caseSensitive) !== null && _c !== void 0 ? _c : false,
1070
1124
  end: true,
1071
1125
  },
1072
1126
  };
@@ -1191,24 +1245,30 @@ class StackManager extends React.PureComponent {
1191
1245
  if (this.outletMountPath && !currentPathname.startsWith(this.outletMountPath)) {
1192
1246
  return undefined;
1193
1247
  }
1194
- // If this is a nested outlet (has an explicit ID like "main"),
1195
- // we need to figure out what part of the path was already matched
1196
- if (this.id !== 'routerOutlet' && this.ionRouterOutlet) {
1248
+ // Check if this outlet has route children to analyze
1249
+ if (this.ionRouterOutlet) {
1197
1250
  const routeChildren = extractRouteChildren(this.ionRouterOutlet.props.children);
1198
1251
  const { hasRelativeRoutes, hasIndexRoute, hasWildcardRoute } = analyzeRouteChildren(routeChildren);
1199
- const result = computeParentPath({
1200
- currentPathname,
1201
- outletMountPath: this.outletMountPath,
1202
- routeChildren,
1203
- hasRelativeRoutes,
1204
- hasIndexRoute,
1205
- hasWildcardRoute,
1206
- });
1207
- // Update the outlet mount path if it was set
1208
- if (result.outletMountPath && !this.outletMountPath) {
1209
- this.outletMountPath = result.outletMountPath;
1252
+ // Root outlets have IDs like 'routerOutlet' or 'routerOutlet-2'
1253
+ // But even outlets with auto-generated IDs may need parent path computation
1254
+ // if they have relative routes (indicating they're nested outlets)
1255
+ const isRootOutlet = this.id.startsWith('routerOutlet');
1256
+ const needsParentPath = !isRootOutlet || hasRelativeRoutes || hasIndexRoute;
1257
+ if (needsParentPath) {
1258
+ const result = computeParentPath({
1259
+ currentPathname,
1260
+ outletMountPath: this.outletMountPath,
1261
+ routeChildren,
1262
+ hasRelativeRoutes,
1263
+ hasIndexRoute,
1264
+ hasWildcardRoute,
1265
+ });
1266
+ // Update the outlet mount path if it was set
1267
+ if (result.outletMountPath && !this.outletMountPath) {
1268
+ this.outletMountPath = result.outletMountPath;
1269
+ }
1270
+ return result.parentPath;
1210
1271
  }
1211
- return result.parentPath;
1212
1272
  }
1213
1273
  return this.outletMountPath;
1214
1274
  }
@@ -1256,12 +1316,37 @@ class StackManager extends React.PureComponent {
1256
1316
  * Determines if the leaving view item should be unmounted after a transition.
1257
1317
  */
1258
1318
  shouldUnmountLeavingView(routeInfo, enteringViewItem, leavingViewItem) {
1319
+ var _a, _b, _c, _d;
1259
1320
  if (!leavingViewItem) {
1260
1321
  return false;
1261
1322
  }
1262
1323
  if (routeInfo.routeAction === 'replace') {
1263
- return true;
1324
+ const enteringRoutePath = (_b = (_a = enteringViewItem === null || enteringViewItem === void 0 ? void 0 : enteringViewItem.reactElement) === null || _a === void 0 ? void 0 : _a.props) === null || _b === void 0 ? void 0 : _b.path;
1325
+ const leavingRoutePath = (_d = (_c = leavingViewItem === null || leavingViewItem === void 0 ? void 0 : leavingViewItem.reactElement) === null || _c === void 0 ? void 0 : _c.props) === null || _d === void 0 ? void 0 : _d.path;
1326
+ // Never unmount the root path "/" - it's the main entry point for back navigation
1327
+ if (leavingRoutePath === '/' || leavingRoutePath === '') {
1328
+ return false;
1329
+ }
1330
+ if (enteringRoutePath && leavingRoutePath) {
1331
+ // Get parent paths to check if routes share a common parent
1332
+ const getParentPath = (path) => {
1333
+ const normalized = path.replace(/\/\*$/, ''); // Remove trailing /*
1334
+ const lastSlash = normalized.lastIndexOf('/');
1335
+ return lastSlash > 0 ? normalized.substring(0, lastSlash) : '/';
1336
+ };
1337
+ const enteringParent = getParentPath(enteringRoutePath);
1338
+ const leavingParent = getParentPath(leavingRoutePath);
1339
+ // Unmount if:
1340
+ // 1. Routes are siblings (same parent, e.g., /page1 and /page2, or /foo/page1 and /foo/page2)
1341
+ // 2. Entering is a child of leaving (redirect, e.g., /tabs -> /tabs/tab1)
1342
+ const areSiblings = enteringParent === leavingParent && enteringParent !== '/';
1343
+ const isChildRedirect = enteringRoutePath.startsWith(leavingRoutePath) ||
1344
+ (leavingRoutePath.endsWith('/*') && enteringRoutePath.startsWith(leavingRoutePath.slice(0, -2)));
1345
+ return areSiblings || isChildRedirect;
1346
+ }
1347
+ return false;
1264
1348
  }
1349
+ // For non-replace actions, only unmount for back navigation (not forward push)
1265
1350
  const isForwardPush = routeInfo.routeAction === 'push' && routeInfo.routeDirection === 'forward';
1266
1351
  if (!isForwardPush && routeInfo.routeDirection !== 'none' && enteringViewItem !== leavingViewItem) {
1267
1352
  return true;
@@ -1297,7 +1382,9 @@ class StackManager extends React.PureComponent {
1297
1382
  */
1298
1383
  handleOutOfContextNestedOutlet(parentPath, leavingViewItem) {
1299
1384
  var _a;
1300
- if (this.id === 'routerOutlet' || parentPath !== undefined || !this.ionRouterOutlet) {
1385
+ // Root outlets have IDs like 'routerOutlet' or 'routerOutlet-2'
1386
+ const isRootOutlet = this.id.startsWith('routerOutlet');
1387
+ if (isRootOutlet || parentPath !== undefined || !this.ionRouterOutlet) {
1301
1388
  return false;
1302
1389
  }
1303
1390
  const routesChildren = (_a = getRoutesChildren(this.ionRouterOutlet.props.children)) !== null && _a !== void 0 ? _a : this.ionRouterOutlet.props.children;
@@ -1322,7 +1409,9 @@ class StackManager extends React.PureComponent {
1322
1409
  * Returns true if the transition should be aborted.
1323
1410
  */
1324
1411
  handleNoMatchingRoute(enteringRoute, enteringViewItem, leavingViewItem) {
1325
- if (this.id === 'routerOutlet' || enteringRoute || enteringViewItem) {
1412
+ // Root outlets have IDs like 'routerOutlet' or 'routerOutlet-2'
1413
+ const isRootOutlet = this.id.startsWith('routerOutlet');
1414
+ if (isRootOutlet || enteringRoute || enteringViewItem) {
1326
1415
  return false;
1327
1416
  }
1328
1417
  // Hide any visible views in this outlet since it has no matching route
@@ -1338,8 +1427,6 @@ class StackManager extends React.PureComponent {
1338
1427
  */
1339
1428
  handleReadyEnteringView(routeInfo, enteringViewItem, leavingViewItem, shouldUnmountLeavingViewItem) {
1340
1429
  var _a, _b;
1341
- // Ensure the entering view is not hidden from previous navigations
1342
- showIonPageElement(enteringViewItem.ionPageElement);
1343
1430
  // Handle same view item case (e.g., parameterized route changes)
1344
1431
  if (enteringViewItem === leavingViewItem) {
1345
1432
  const routePath = (_b = (_a = enteringViewItem.reactElement) === null || _a === void 0 ? void 0 : _a.props) === null || _b === void 0 ? void 0 : _b.path;
@@ -1363,24 +1450,76 @@ class StackManager extends React.PureComponent {
1363
1450
  if (!leavingViewItem && this.props.routeInfo.prevRouteLastPathname) {
1364
1451
  leavingViewItem = this.context.findViewItemByPathname(this.props.routeInfo.prevRouteLastPathname, this.id);
1365
1452
  }
1366
- // Skip transition if entering view is visible and leaving view is not
1367
- if (enteringViewItem.ionPageElement &&
1368
- isViewVisible(enteringViewItem.ionPageElement) &&
1369
- leavingViewItem !== undefined &&
1370
- leavingViewItem.ionPageElement &&
1371
- !isViewVisible(leavingViewItem.ionPageElement)) {
1372
- return;
1373
- }
1453
+ // Ensure the entering view is marked as mounted.
1454
+ // This is critical for views that were previously unmounted (e.g., navigating back to home).
1455
+ // When mount=false, the ViewLifeCycleManager doesn't render the IonPage, so the
1456
+ // ionPageElement reference becomes stale. By setting mount=true, we ensure the view
1457
+ // gets re-rendered and a new IonPage is created.
1458
+ if (!enteringViewItem.mount) {
1459
+ enteringViewItem.mount = true;
1460
+ }
1461
+ // Check visibility state BEFORE showing the entering view.
1462
+ // This must be done before showIonPageElement to get accurate visibility state.
1463
+ const enteringWasVisible = enteringViewItem.ionPageElement && isViewVisible(enteringViewItem.ionPageElement);
1464
+ const leavingIsHidden = leavingViewItem !== undefined && leavingViewItem.ionPageElement && !isViewVisible(leavingViewItem.ionPageElement);
1374
1465
  // Check for duplicate transition
1375
1466
  const currentTransition = {
1376
1467
  enteringId: enteringViewItem.id,
1377
1468
  leavingId: leavingViewItem === null || leavingViewItem === void 0 ? void 0 : leavingViewItem.id,
1378
1469
  };
1379
- if (leavingViewItem &&
1470
+ const isDuplicateTransition = leavingViewItem &&
1380
1471
  this.lastTransition &&
1381
1472
  this.lastTransition.leavingId &&
1382
1473
  this.lastTransition.enteringId === currentTransition.enteringId &&
1383
- this.lastTransition.leavingId === currentTransition.leavingId) {
1474
+ this.lastTransition.leavingId === currentTransition.leavingId;
1475
+ // Skip transition if entering view was ALREADY visible and leaving view is not visible.
1476
+ // This indicates the transition has already been performed (e.g., via swipe gesture).
1477
+ // IMPORTANT: Only skip if both ionPageElements are the same as when the transition was last done.
1478
+ // If the leaving view's ionPageElement changed (e.g., component re-rendered with different IonPage),
1479
+ // we should NOT skip because the DOM state is inconsistent.
1480
+ if (enteringWasVisible && leavingIsHidden && isDuplicateTransition) {
1481
+ // For swipe-to-go-back, the transition animation was handled by the gesture.
1482
+ // We still need to set mount=false so React unmounts the leaving view.
1483
+ // Only do this when skipTransition is set (indicating gesture completion).
1484
+ if (this.skipTransition &&
1485
+ shouldUnmountLeavingViewItem &&
1486
+ leavingViewItem &&
1487
+ enteringViewItem !== leavingViewItem) {
1488
+ leavingViewItem.mount = false;
1489
+ // Call transitionPage with duration 0 to trigger ionViewDidLeave lifecycle
1490
+ // which is needed for ViewLifeCycleManager to remove the view.
1491
+ this.transitionPage(routeInfo, enteringViewItem, leavingViewItem, 'back');
1492
+ }
1493
+ // Clear skipTransition since we're not calling transitionPage which normally clears it
1494
+ this.skipTransition = false;
1495
+ // Must call forceUpdate to trigger re-render after mount state change
1496
+ this.forceUpdate();
1497
+ return;
1498
+ }
1499
+ // Ensure the entering view is not hidden from previous navigations
1500
+ // This must happen AFTER the visibility check above
1501
+ showIonPageElement(enteringViewItem.ionPageElement);
1502
+ // Skip if this is a duplicate transition (but visibility state didn't match above)
1503
+ // OR if skipTransition is set (swipe gesture already handled the animation)
1504
+ if (isDuplicateTransition || this.skipTransition) {
1505
+ // For swipe-to-go-back, we still need to handle unmounting even if visibility
1506
+ // conditions aren't fully met (animation might still be in progress)
1507
+ if (this.skipTransition &&
1508
+ shouldUnmountLeavingViewItem &&
1509
+ leavingViewItem &&
1510
+ enteringViewItem !== leavingViewItem) {
1511
+ leavingViewItem.mount = false;
1512
+ // For swipe-to-go-back, we need to call transitionPage with duration 0 to
1513
+ // trigger the ionViewDidLeave lifecycle event. The ViewLifeCycleManager
1514
+ // uses componentCanBeDestroyed callback to remove the view, which is
1515
+ // only called from ionViewDidLeave. Since the gesture animation already
1516
+ // completed before mount=false was set, we need to re-fire the lifecycle.
1517
+ this.transitionPage(routeInfo, enteringViewItem, leavingViewItem, 'back');
1518
+ }
1519
+ // Clear skipTransition since we're not calling transitionPage which normally clears it
1520
+ this.skipTransition = false;
1521
+ // Must call forceUpdate to trigger re-render after mount state change
1522
+ this.forceUpdate();
1384
1523
  return;
1385
1524
  }
1386
1525
  this.lastTransition = currentTransition;
@@ -1392,14 +1531,28 @@ class StackManager extends React.PureComponent {
1392
1531
  }
1393
1532
  }
1394
1533
  /**
1395
- * Handles the delayed unmount of the leaving view item after a replace action.
1534
+ * Handles the delayed unmount of the leaving view item.
1535
+ * For 'replace' actions: handles container route transitions specially.
1536
+ * For back navigation: explicitly unmounts because the ionViewDidLeave lifecycle
1537
+ * fires DURING transitionPage, but mount=false is set AFTER.
1538
+ *
1539
+ * @param routeInfo Current route information
1540
+ * @param enteringViewItem The view being navigated to
1541
+ * @param leavingViewItem The view being navigated from
1396
1542
  */
1397
1543
  handleLeavingViewUnmount(routeInfo, enteringViewItem, leavingViewItem) {
1398
1544
  var _a, _b, _c, _d, _e, _f;
1399
- if (routeInfo.routeAction !== 'replace' || !leavingViewItem.ionPageElement) {
1545
+ if (!leavingViewItem.ionPageElement) {
1546
+ return;
1547
+ }
1548
+ // For push/pop actions, do NOT unmount - views are cached for navigation history.
1549
+ // Push: Forward navigation caches views for back navigation
1550
+ // Pop: Back navigation should not unmount the entering view's history
1551
+ // Only 'replace' actions should actually unmount views since they replace history.
1552
+ if (routeInfo.routeAction !== 'replace') {
1400
1553
  return;
1401
1554
  }
1402
- // Check if we should skip removal for nested outlet redirects
1555
+ // For replace actions, check if we should skip removal for nested outlet redirects
1403
1556
  const enteringRoutePath = (_b = (_a = enteringViewItem.reactElement) === null || _a === void 0 ? void 0 : _a.props) === null || _b === void 0 ? void 0 : _b.path;
1404
1557
  const leavingRoutePath = (_d = (_c = leavingViewItem.reactElement) === null || _c === void 0 ? void 0 : _c.props) === null || _d === void 0 ? void 0 : _d.path;
1405
1558
  const isEnteringContainerRoute = enteringRoutePath && enteringRoutePath.endsWith('/*');
@@ -1415,6 +1568,8 @@ class StackManager extends React.PureComponent {
1415
1568
  const viewToUnmount = leavingViewItem;
1416
1569
  setTimeout(() => {
1417
1570
  this.context.unMountViewItem(viewToUnmount);
1571
+ // Trigger re-render to remove the view from DOM
1572
+ this.forceUpdate();
1418
1573
  }, VIEW_UNMOUNT_DELAY_MS);
1419
1574
  }
1420
1575
  /**
@@ -1459,6 +1614,8 @@ class StackManager extends React.PureComponent {
1459
1614
  this.transitionPage(routeInfo, latestEnteringView, latestLeavingView !== null && latestLeavingView !== void 0 ? latestLeavingView : undefined);
1460
1615
  if (shouldUnmountLeavingViewItem && latestLeavingView && latestEnteringView !== latestLeavingView) {
1461
1616
  latestLeavingView.mount = false;
1617
+ // Call handleLeavingViewUnmount to ensure the view is properly removed
1618
+ this.handleLeavingViewUnmount(routeInfo, latestEnteringView, latestLeavingView);
1462
1619
  }
1463
1620
  this.forceUpdate();
1464
1621
  }
@@ -1582,7 +1739,12 @@ class StackManager extends React.PureComponent {
1582
1739
  this.context.addViewItem(enteringViewItem);
1583
1740
  }
1584
1741
  // Handle transition based on ion-page element availability
1585
- if (enteringViewItem && enteringViewItem.ionPageElement) {
1742
+ // Check if the ionPageElement is still in the document.
1743
+ // If the view was previously unmounted (mount=false), the ViewLifeCycleManager
1744
+ // removes the React component from the tree, which removes the IonPage from the DOM.
1745
+ // The ionPageElement reference becomes stale and we need to wait for a new one.
1746
+ const ionPageIsInDocument = (enteringViewItem === null || enteringViewItem === void 0 ? void 0 : enteringViewItem.ionPageElement) && document.body.contains(enteringViewItem.ionPageElement);
1747
+ if (enteringViewItem && ionPageIsInDocument) {
1586
1748
  // Clear waiting state
1587
1749
  if (this.waitingForIonPage) {
1588
1750
  this.waitingForIonPage = false;
@@ -1593,8 +1755,17 @@ class StackManager extends React.PureComponent {
1593
1755
  }
1594
1756
  this.handleReadyEnteringView(routeInfo, enteringViewItem, leavingViewItem, shouldUnmountLeavingViewItem);
1595
1757
  }
1596
- else if (enteringViewItem && !enteringViewItem.ionPageElement) {
1758
+ else if (enteringViewItem && !ionPageIsInDocument) {
1597
1759
  // Wait for ion-page to mount
1760
+ // This handles both: no ionPageElement, or stale ionPageElement (not in document)
1761
+ // Clear stale reference if the element is no longer in the document
1762
+ if (enteringViewItem.ionPageElement && !document.body.contains(enteringViewItem.ionPageElement)) {
1763
+ enteringViewItem.ionPageElement = undefined;
1764
+ }
1765
+ // Ensure the view is marked as mounted so ViewLifeCycleManager renders the IonPage
1766
+ if (!enteringViewItem.mount) {
1767
+ enteringViewItem.mount = true;
1768
+ }
1598
1769
  this.handleWaitingForIonPage(routeInfo, enteringViewItem, leavingViewItem, shouldUnmountLeavingViewItem);
1599
1770
  return;
1600
1771
  }
@@ -1626,6 +1797,19 @@ class StackManager extends React.PureComponent {
1626
1797
  const foundView = this.context.findViewItemByRouteInfo(routeInfo, this.id);
1627
1798
  if (foundView) {
1628
1799
  const oldPageElement = foundView.ionPageElement;
1800
+ /**
1801
+ * FIX for issue #28878: Reject orphaned IonPage registrations.
1802
+ *
1803
+ * When a component conditionally renders different IonPages (e.g., list vs empty state)
1804
+ * using React keys, and state changes simultaneously with navigation, the new IonPage
1805
+ * tries to register for a route we're navigating away from. This creates a stale view.
1806
+ *
1807
+ * Only reject if both pageIds exist and differ, to allow nested outlet registrations.
1808
+ */
1809
+ if (this.shouldRejectOrphanedPage(page, oldPageElement, routeInfo)) {
1810
+ this.hideAndRemoveOrphanedPage(page);
1811
+ return;
1812
+ }
1629
1813
  foundView.ionPageElement = page;
1630
1814
  foundView.ionRoute = true;
1631
1815
  /**
@@ -1639,6 +1823,35 @@ class StackManager extends React.PureComponent {
1639
1823
  }
1640
1824
  this.handlePageTransition(routeInfo);
1641
1825
  }
1826
+ /**
1827
+ * Determines if a new IonPage registration should be rejected as orphaned.
1828
+ * This happens when a component re-renders with a different IonPage while navigating away.
1829
+ */
1830
+ shouldRejectOrphanedPage(newPage, oldPageElement, routeInfo) {
1831
+ if (!oldPageElement || oldPageElement === newPage) {
1832
+ return false;
1833
+ }
1834
+ const newPageId = newPage.getAttribute('data-pageid');
1835
+ const oldPageId = oldPageElement.getAttribute('data-pageid');
1836
+ // Only reject if both pageIds exist and are different
1837
+ if (!newPageId || !oldPageId || newPageId === oldPageId) {
1838
+ return false;
1839
+ }
1840
+ // Reject only if we're navigating away from this route
1841
+ return this.props.routeInfo.pathname !== routeInfo.pathname;
1842
+ }
1843
+ /**
1844
+ * Hides an orphaned IonPage and schedules its removal from the DOM.
1845
+ */
1846
+ hideAndRemoveOrphanedPage(page) {
1847
+ page.classList.add('ion-page-hidden');
1848
+ page.setAttribute('aria-hidden', 'true');
1849
+ setTimeout(() => {
1850
+ if (page.parentElement) {
1851
+ page.remove();
1852
+ }
1853
+ }, VIEW_UNMOUNT_DELAY_MS);
1854
+ }
1642
1855
  /**
1643
1856
  * Configures the router outlet for the swipe-to-go-back gesture.
1644
1857
  *
@@ -1654,11 +1867,23 @@ class StackManager extends React.PureComponent {
1654
1867
  }
1655
1868
  const { routeInfo } = this.props;
1656
1869
  const swipeBackRouteInfo = this.getSwipeBackRouteInfo();
1657
- const enteringViewItem = this.context.findViewItemByRouteInfo(swipeBackRouteInfo, this.id, false);
1870
+ // First try to find the view in the current outlet
1871
+ let enteringViewItem = this.context.findViewItemByRouteInfo(swipeBackRouteInfo, this.id, false);
1872
+ // If not found in current outlet, search all outlets (for cross-outlet swipe back)
1873
+ if (!enteringViewItem) {
1874
+ enteringViewItem = this.context.findViewItemByRouteInfo(swipeBackRouteInfo, undefined, false);
1875
+ }
1876
+ // Check if the ionPageElement is still in the document.
1877
+ // A view might have mount=false but still have its ionPageElement in the DOM
1878
+ // (due to timing differences in unmounting).
1879
+ const ionPageInDocument = Boolean((enteringViewItem === null || enteringViewItem === void 0 ? void 0 : enteringViewItem.ionPageElement) && document.body.contains(enteringViewItem.ionPageElement));
1658
1880
  const canStartSwipe = !!enteringViewItem &&
1659
- // The root url '/' is treated as the first view item (but is never mounted),
1660
- // so we do not want to swipe back to the root url.
1661
- enteringViewItem.mount &&
1881
+ // Check if we can swipe to this view. Either:
1882
+ // 1. The view is mounted (mount=true), OR
1883
+ // 2. The view's ionPageElement is still in the document
1884
+ // The second case handles views that have been marked for unmount but haven't
1885
+ // actually been removed from the DOM yet.
1886
+ (enteringViewItem.mount || ionPageInDocument) &&
1662
1887
  // When on the first page it is possible for findViewItemByRouteInfo to
1663
1888
  // return the exact same view you are currently on.
1664
1889
  // Make sure that we are not swiping back to the same instances of a view.
@@ -1668,8 +1893,18 @@ class StackManager extends React.PureComponent {
1668
1893
  const onStart = async () => {
1669
1894
  const { routeInfo } = this.props;
1670
1895
  const swipeBackRouteInfo = this.getSwipeBackRouteInfo();
1671
- const enteringViewItem = this.context.findViewItemByRouteInfo(swipeBackRouteInfo, this.id, false);
1896
+ // First try to find the view in the current outlet, then search all outlets
1897
+ let enteringViewItem = this.context.findViewItemByRouteInfo(swipeBackRouteInfo, this.id, false);
1898
+ if (!enteringViewItem) {
1899
+ enteringViewItem = this.context.findViewItemByRouteInfo(swipeBackRouteInfo, undefined, false);
1900
+ }
1672
1901
  const leavingViewItem = this.context.findViewItemByRouteInfo(routeInfo, this.id, false);
1902
+ // Ensure the entering view is mounted so React keeps rendering it during the gesture.
1903
+ // This is important when the view was previously marked for unmount but its
1904
+ // ionPageElement is still in the DOM.
1905
+ if (enteringViewItem && !enteringViewItem.mount) {
1906
+ enteringViewItem.mount = true;
1907
+ }
1673
1908
  // When the gesture starts, kick off a transition controlled via swipe gesture
1674
1909
  if (enteringViewItem && leavingViewItem) {
1675
1910
  await this.transitionPage(routeInfo, enteringViewItem, leavingViewItem, 'back', true);
@@ -1686,7 +1921,11 @@ class StackManager extends React.PureComponent {
1686
1921
  // Swipe gesture was aborted - re-hide the page that was going to enter
1687
1922
  const { routeInfo } = this.props;
1688
1923
  const swipeBackRouteInfo = this.getSwipeBackRouteInfo();
1689
- const enteringViewItem = this.context.findViewItemByRouteInfo(swipeBackRouteInfo, this.id, false);
1924
+ // First try to find the view in the current outlet, then search all outlets
1925
+ let enteringViewItem = this.context.findViewItemByRouteInfo(swipeBackRouteInfo, this.id, false);
1926
+ if (!enteringViewItem) {
1927
+ enteringViewItem = this.context.findViewItemByRouteInfo(swipeBackRouteInfo, undefined, false);
1928
+ }
1690
1929
  const leavingViewItem = this.context.findViewItemByRouteInfo(routeInfo, this.id, false);
1691
1930
  // Don't hide if entering and leaving are the same (parameterized route edge case)
1692
1931
  if (enteringViewItem !== leavingViewItem && (enteringViewItem === null || enteringViewItem === void 0 ? void 0 : enteringViewItem.ionPageElement) !== undefined) {
@@ -1869,27 +2108,59 @@ function findRouteByRouteInfo(node, routeInfo, parentPath) {
1869
2108
  });
1870
2109
  // For nested routes in React Router 6, we need to extract the relative path
1871
2110
  // that this outlet should be responsible for matching
1872
- let pathnameToMatch = routeInfo.pathname;
2111
+ const originalPathname = routeInfo.pathname;
2112
+ let relativePathnameToMatch = routeInfo.pathname;
1873
2113
  // Check if we have relative routes (routes that don't start with '/')
1874
2114
  const hasRelativeRoutes = sortedRoutes.some((r) => r.props.path && !r.props.path.startsWith('/'));
1875
2115
  const hasIndexRoute = sortedRoutes.some((r) => r.props.index);
1876
2116
  // SIMPLIFIED: Trust React Router 6's matching more, compute relative path when parent is known
1877
2117
  if ((hasRelativeRoutes || hasIndexRoute) && parentPath) {
1878
2118
  const parentPrefix = parentPath.replace('/*', '');
1879
- const normalizedParent = stripTrailingSlash(parentPrefix);
2119
+ // Normalize both paths to start with '/' for consistent comparison
2120
+ const normalizedParent = stripTrailingSlash(parentPrefix.startsWith('/') ? parentPrefix : `/${parentPrefix}`);
1880
2121
  const normalizedPathname = stripTrailingSlash(routeInfo.pathname);
1881
2122
  // Only compute relative path if pathname is within parent scope
1882
2123
  if (normalizedPathname.startsWith(normalizedParent + '/') || normalizedPathname === normalizedParent) {
1883
2124
  const pathSegments = routeInfo.pathname.split('/').filter(Boolean);
1884
2125
  const parentSegments = normalizedParent.split('/').filter(Boolean);
1885
2126
  const relativeSegments = pathSegments.slice(parentSegments.length);
1886
- pathnameToMatch = relativeSegments.join('/'); // Empty string is valid for index routes
2127
+ relativePathnameToMatch = relativeSegments.join('/'); // Empty string is valid for index routes
1887
2128
  }
1888
2129
  }
1889
2130
  // Find the first matching route
1890
2131
  for (const child of sortedRoutes) {
2132
+ const childPath = child.props.path;
2133
+ const isAbsoluteRoute = childPath && childPath.startsWith('/');
2134
+ // Determine which pathname to match against:
2135
+ // - For absolute routes: use the original full pathname
2136
+ // - For relative routes with a parent: use the computed relative pathname
2137
+ // - For relative routes at root level (no parent): use the original pathname
2138
+ // (matchPath will handle the relative-to-absolute normalization)
2139
+ const pathnameToMatch = isAbsoluteRoute ? originalPathname : relativePathnameToMatch;
2140
+ // Determine the path portion to match:
2141
+ // - For absolute routes: use derivePathnameToMatch
2142
+ // - For relative routes at root level (no parent): use original pathname
2143
+ // directly since matchPath normalizes both path and pathname
2144
+ // - For relative routes with parent: use derivePathnameToMatch for wildcards,
2145
+ // or the computed relative pathname for non-wildcards
2146
+ let pathForMatch;
2147
+ if (isAbsoluteRoute) {
2148
+ pathForMatch = derivePathnameToMatch(pathnameToMatch, childPath);
2149
+ }
2150
+ else if (!parentPath && childPath) {
2151
+ // Root-level relative route: use the full pathname and let matchPath
2152
+ // handle the normalization (it adds '/' to both path and pathname)
2153
+ pathForMatch = originalPathname;
2154
+ }
2155
+ else if (childPath && childPath.includes('*')) {
2156
+ // Relative wildcard route with parent path: use derivePathnameToMatch
2157
+ pathForMatch = derivePathnameToMatch(pathnameToMatch, childPath);
2158
+ }
2159
+ else {
2160
+ pathForMatch = pathnameToMatch;
2161
+ }
1891
2162
  const match = matchPath({
1892
- pathname: pathnameToMatch,
2163
+ pathname: pathForMatch,
1893
2164
  componentProps: child.props,
1894
2165
  });
1895
2166
  if (match) {