@ionic/react-router 8.8.1-dev.11773168858.1f9c0eb8 → 8.8.1-dev.11773432149.19826d0c

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
@@ -1,11 +1,11 @@
1
1
  import { __rest } from 'tslib';
2
2
  import React, { useRef, useState, useEffect, useCallback } from 'react';
3
- import { Route, matchPath as matchPath$1, Routes, Navigate, UNSAFE_RouteContext, useLocation, useNavigate, BrowserRouter, useNavigationType, HashRouter } from 'react-router-dom';
4
- import { ViewStacks, generateId, IonRoute, ViewLifeCycleManager, StackContext, RouteManagerContext, getConfig, LocationHistory, NavManager } from '@ionic/react';
3
+ import { Route, matchPath as matchPath$1, Routes, Navigate, UNSAFE_RouteContext, matchRoutes, useLocation, useNavigate, BrowserRouter, useNavigationType, HashRouter } from 'react-router-dom';
4
+ import { IonRoute, ViewStacks, generateId, ViewLifeCycleManager, StackContext, RouteManagerContext, getConfig, LocationHistory, NavManager } from '@ionic/react';
5
5
  import { MemoryRouter, useLocation as useLocation$1, useNavigationType as useNavigationType$1 } from 'react-router';
6
6
 
7
- const IonRouteInner = ({ path, element }) => {
8
- return React.createElement(Route, { path: path, element: element });
7
+ const IonRouteInner = ({ path, index, caseSensitive, element }) => {
8
+ return React.createElement(Route, { path: path, index: index, caseSensitive: caseSensitive, element: element });
9
9
  };
10
10
 
11
11
  /**
@@ -57,7 +57,7 @@ const matchPath = ({ pathname, componentProps }) => {
57
57
  const match = matchPath$1(matchOptions, normalizedPathname);
58
58
  if (match) {
59
59
  // Adjust the match to remove the leading '/' we added
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 }) });
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 }) });
61
61
  }
62
62
  return null;
63
63
  }
@@ -162,6 +162,16 @@ const computeCommonPrefix = (paths) => {
162
162
  }
163
163
  return commonSegments.length > 0 ? '/' + commonSegments.join('/') : '';
164
164
  };
165
+ /**
166
+ * Checks if a pathname falls within the scope of a mount path using
167
+ * segment-aware comparison. Prevents false positives like "/tabs-secondary"
168
+ * matching mount path "/tabs".
169
+ */
170
+ const isPathnameInScope = (pathname, mountPath) => {
171
+ if (mountPath === '/')
172
+ return true;
173
+ return pathname === mountPath || pathname.startsWith(mountPath + '/');
174
+ };
165
175
  /**
166
176
  * Checks if a route path is a "splat-only" route (just `*` or `/*`).
167
177
  */
@@ -218,9 +228,20 @@ const analyzeRouteChildren = (routeChildren) => {
218
228
  const findSpecificMatch = (routeChildren, remainingPath) => {
219
229
  return routeChildren.some((route) => isSpecificRouteMatch(route, remainingPath) || matchesEmbeddedWildcardRoute(route, remainingPath));
220
230
  };
231
+ /**
232
+ * Returns the first route that matches as a specific (non-wildcard, non-index) route.
233
+ */
234
+ const findFirstSpecificMatchingRoute = (routeChildren, remainingPath) => {
235
+ return routeChildren.find((route) => isSpecificRouteMatch(route, remainingPath) || matchesEmbeddedWildcardRoute(route, remainingPath));
236
+ };
221
237
  /**
222
238
  * Checks if any specific route could plausibly match the remaining path.
223
239
  * Used to determine if we should fall back to a wildcard match.
240
+ *
241
+ * Uses exact first-segment matching: the remaining path's first segment
242
+ * must exactly equal a route's first segment to block the wildcard.
243
+ * The outlet's mount path is always known from React Router's RouteContext,
244
+ * so no heuristic-based discovery is needed.
224
245
  */
225
246
  const couldSpecificRouteMatch = (routeChildren, remainingPath) => {
226
247
  const remainingFirstSegment = remainingPath.split('/')[0];
@@ -233,26 +254,9 @@ const couldSpecificRouteMatch = (routeChildren, remainingPath) => {
233
254
  const routeFirstSegment = routePath.split('/')[0].replace(/[*:]/g, '');
234
255
  if (!routeFirstSegment)
235
256
  return false;
236
- // Check for prefix overlap (either direction)
237
- return (routeFirstSegment.startsWith(remainingFirstSegment.slice(0, 3)) ||
238
- remainingFirstSegment.startsWith(routeFirstSegment.slice(0, 3)));
257
+ return routeFirstSegment === remainingFirstSegment;
239
258
  });
240
259
  };
241
- /**
242
- * Checks for index route match when remaining path is empty.
243
- * Index routes only match at the outlet's mount path level.
244
- */
245
- const checkIndexMatch = (parentPath, remainingPath, hasIndexRoute, outletMountPath) => {
246
- if ((remainingPath === '' || remainingPath === '/') && hasIndexRoute) {
247
- if (outletMountPath) {
248
- // Index should only match at the existing mount path
249
- return parentPath === outletMountPath ? parentPath : undefined;
250
- }
251
- // No mount path yet - this would establish it
252
- return parentPath;
253
- }
254
- return undefined;
255
- };
256
260
  /**
257
261
  * Determines the best parent path from the available matches.
258
262
  * Priority: specific > wildcard > index
@@ -287,30 +291,55 @@ const computeAbsoluteRoutesParentPath = (routeChildren, currentPathname, outletM
287
291
  * Computes the parent path for a nested outlet based on the current pathname
288
292
  * and the outlet's route configuration.
289
293
  *
290
- * The algorithm finds the shortest parent path where a route matches the remaining path.
291
- * Priority: specific routes > wildcard routes > index routes (only at mount point)
294
+ * When the mount path is known (seeded from React Router's RouteContext), the
295
+ * parent path is simply the mount path no iterative discovery needed. The
296
+ * iterative fallback only runs for outlets where RouteContext doesn't provide
297
+ * a parent match (typically root-level outlets on first render).
292
298
  *
293
299
  * @param options The options for computing the parent path.
294
300
  * @returns The computed parent path result.
295
301
  */
296
302
  const computeParentPath = (options) => {
297
303
  const { currentPathname, outletMountPath, routeChildren, hasRelativeRoutes, hasIndexRoute, hasWildcardRoute } = options;
298
- // If pathname is outside the established mount path scope, skip computation
299
- if (outletMountPath && !currentPathname.startsWith(outletMountPath)) {
304
+ // If pathname is outside the established mount path scope, skip computation.
305
+ // Use segment-aware comparison: /tabs-secondary must NOT match /tabs scope.
306
+ if (outletMountPath && !isPathnameInScope(currentPathname, outletMountPath)) {
300
307
  return { parentPath: undefined, outletMountPath };
301
308
  }
302
- if ((hasRelativeRoutes || hasIndexRoute) && currentPathname.includes('/')) {
309
+ // Fast path: when the mount path is known (from React Router's RouteContext),
310
+ // the parent path IS the mount path. The iterative segment-by-segment discovery
311
+ // below was needed when the mount depth had to be guessed from URL structure,
312
+ // but with RouteContext we already know exactly where this outlet is mounted.
313
+ if (outletMountPath && (hasRelativeRoutes || hasIndexRoute)) {
314
+ return { parentPath: outletMountPath, outletMountPath };
315
+ }
316
+ // Fallback: mount path not yet known. Iterate through path segments to discover
317
+ // the correct parent depth. This only runs on first render of outlets where
318
+ // RouteContext doesn't provide a parent match (typically root-level outlets,
319
+ // which usually have absolute routes and take the absolute routes path below).
320
+ if (!outletMountPath && (hasRelativeRoutes || hasIndexRoute) && currentPathname.includes('/')) {
303
321
  const segments = currentPathname.split('/').filter(Boolean);
304
322
  if (segments.length >= 1) {
305
323
  let firstSpecificMatch;
306
324
  let firstWildcardMatch;
307
325
  let indexMatchAtMount;
308
- // Iterate through path segments to find the shortest matching parent path
309
326
  for (let i = 1; i <= segments.length; i++) {
310
327
  const parentPath = '/' + segments.slice(0, i).join('/');
311
328
  const remainingPath = segments.slice(i).join('/');
312
329
  // Check for specific route match (highest priority)
313
330
  if (!firstSpecificMatch && findSpecificMatch(routeChildren, remainingPath)) {
331
+ // Don't let empty/default path routes (path="" or undefined) drive
332
+ // the parent deeper than a wildcard match. An empty path route matching
333
+ // when remainingPath is "" just means all segments were consumed.
334
+ if (firstWildcardMatch) {
335
+ const matchingRoute = findFirstSpecificMatchingRoute(routeChildren, remainingPath);
336
+ if (matchingRoute) {
337
+ const matchingPath = matchingRoute.props.path;
338
+ if (!matchingPath || matchingPath === '') {
339
+ continue;
340
+ }
341
+ }
342
+ }
314
343
  firstSpecificMatch = parentPath;
315
344
  break;
316
345
  }
@@ -322,9 +351,8 @@ const computeParentPath = (options) => {
322
351
  }
323
352
  }
324
353
  // Check for index route match
325
- const indexMatch = checkIndexMatch(parentPath, remainingPath, hasIndexRoute, outletMountPath);
326
- if (indexMatch) {
327
- indexMatchAtMount = indexMatch;
354
+ if ((remainingPath === '' || remainingPath === '/') && hasIndexRoute) {
355
+ indexMatchAtMount = parentPath;
328
356
  }
329
357
  }
330
358
  // Fallback: check root level for embedded wildcard routes (e.g., "tab1/*")
@@ -335,12 +363,7 @@ const computeParentPath = (options) => {
335
363
  }
336
364
  }
337
365
  const bestPath = selectBestMatch(firstSpecificMatch, firstWildcardMatch, indexMatchAtMount);
338
- // Establish mount path on first successful match
339
- const newOutletMountPath = outletMountPath || bestPath;
340
- if (newOutletMountPath && !currentPathname.startsWith(newOutletMountPath)) {
341
- return { parentPath: undefined, outletMountPath: newOutletMountPath };
342
- }
343
- return { parentPath: bestPath, outletMountPath: newOutletMountPath };
366
+ return { parentPath: bestPath, outletMountPath: bestPath };
344
367
  }
345
368
  }
346
369
  // Handle outlets with only absolute routes
@@ -419,7 +442,7 @@ const getRoutesChildren = (node) => {
419
442
  const extractRouteChildren = (children) => {
420
443
  var _a;
421
444
  const routesChildren = (_a = getRoutesChildren(children)) !== null && _a !== void 0 ? _a : children;
422
- return React.Children.toArray(routesChildren).filter((child) => React.isValidElement(child) && child.type === Route);
445
+ return React.Children.toArray(routesChildren).filter((child) => React.isValidElement(child) && (child.type === Route || child.type === IonRoute));
423
446
  };
424
447
  /**
425
448
  * Checks if a React element is a Navigate component (redirect).
@@ -432,28 +455,57 @@ const isNavigateElement = (element) => {
432
455
  (element.type === Navigate || (typeof element.type === 'function' && element.type.name === 'Navigate')));
433
456
  };
434
457
 
458
+ /**
459
+ * Compares two routes by specificity for sorting (most specific first).
460
+ *
461
+ * Sort order:
462
+ * 1. Index routes come first
463
+ * 2. Wildcard-only routes (* or /*) come last
464
+ * 3. Exact matches (no wildcards/params) before wildcard/param routes
465
+ * 4. Among routes with same status, longer paths are more specific
466
+ */
467
+ const compareRouteSpecificity = (a, b) => {
468
+ // Index routes come first
469
+ if (a.index && !b.index)
470
+ return -1;
471
+ if (!a.index && b.index)
472
+ return 1;
473
+ // Wildcard-only routes (* or /*) should come last
474
+ const aIsWildcardOnly = a.path === '*' || a.path === '/*';
475
+ const bIsWildcardOnly = b.path === '*' || b.path === '/*';
476
+ if (!aIsWildcardOnly && bIsWildcardOnly)
477
+ return -1;
478
+ if (aIsWildcardOnly && !bIsWildcardOnly)
479
+ return 1;
480
+ // Exact matches (no wildcards/params) come before wildcard/param routes
481
+ const aHasWildcard = a.path.includes('*') || a.path.includes(':');
482
+ const bHasWildcard = b.path.includes('*') || b.path.includes(':');
483
+ if (!aHasWildcard && bHasWildcard)
484
+ return -1;
485
+ if (aHasWildcard && !bHasWildcard)
486
+ return 1;
487
+ // Among routes with same wildcard status, longer paths are more specific
488
+ if (a.path.length !== b.path.length) {
489
+ return b.path.length - a.path.length;
490
+ }
491
+ return 0;
492
+ };
435
493
  /**
436
494
  * Sorts view items by route specificity (most specific first).
437
- * - Exact matches (no wildcards/params) come first
438
- * - Among wildcard routes, longer paths are more specific
495
+ *
496
+ * Sort order aligns with findViewItemByPath in ReactRouterViewStack.tsx:
497
+ * 1. Index routes come first
498
+ * 2. Wildcard-only routes (* or /*) come last
499
+ * 3. Exact matches (no wildcards/params) come before wildcard/param routes
500
+ * 4. Among routes with same wildcard status, longer paths are more specific
439
501
  *
440
502
  * @param views The view items to sort.
441
503
  * @returns A new sorted array of view items.
442
504
  */
443
505
  const sortViewsBySpecificity = (views) => {
444
506
  return [...views].sort((a, b) => {
445
- var _a, _b, _c, _d;
446
- const pathA = ((_b = (_a = a.routeData) === null || _a === void 0 ? void 0 : _a.childProps) === null || _b === void 0 ? void 0 : _b.path) || '';
447
- const pathB = ((_d = (_c = b.routeData) === null || _c === void 0 ? void 0 : _c.childProps) === null || _d === void 0 ? void 0 : _d.path) || '';
448
- // Exact matches (no wildcards/params) come first
449
- const aHasWildcard = pathA.includes('*') || pathA.includes(':');
450
- const bHasWildcard = pathB.includes('*') || pathB.includes(':');
451
- if (!aHasWildcard && bHasWildcard)
452
- return -1;
453
- if (aHasWildcard && !bHasWildcard)
454
- return 1;
455
- // Among wildcard routes, longer paths are more specific
456
- return pathB.length - pathA.length;
507
+ var _a, _b, _c, _d, _e, _f, _g, _h;
508
+ return compareRouteSpecificity({ path: ((_b = (_a = a.routeData) === null || _a === void 0 ? void 0 : _a.childProps) === null || _b === void 0 ? void 0 : _b.path) || '', index: !!((_d = (_c = a.routeData) === null || _c === void 0 ? void 0 : _c.childProps) === null || _d === void 0 ? void 0 : _d.index) }, { path: ((_f = (_e = b.routeData) === null || _e === void 0 ? void 0 : _e.childProps) === null || _f === void 0 ? void 0 : _f.path) || '', index: !!((_h = (_g = b.routeData) === null || _g === void 0 ? void 0 : _g.childProps) === null || _h === void 0 ? void 0 : _h.index) });
457
509
  });
458
510
  };
459
511
 
@@ -504,7 +556,7 @@ const computeAbsolutePathnameBase = (routeElement, routeMatch, parentPathnameBas
504
556
  */
505
557
  const getFallbackParamsFromViewItems = (allViewItems, currentOutletId, currentPathname) => {
506
558
  var _a;
507
- const params = {};
559
+ const matchingViews = [];
508
560
  for (const otherViewItem of allViewItems) {
509
561
  if (otherViewItem.outletId === currentOutletId)
510
562
  continue;
@@ -512,10 +564,20 @@ const getFallbackParamsFromViewItems = (allViewItems, currentOutletId, currentPa
512
564
  if ((otherMatch === null || otherMatch === void 0 ? void 0 : otherMatch.params) && Object.keys(otherMatch.params).length > 0) {
513
565
  const matchedPathname = otherMatch.pathnameBase || otherMatch.pathname;
514
566
  if (matchedPathname && currentPathname.startsWith(matchedPathname)) {
515
- Object.assign(params, otherMatch.params);
567
+ matchingViews.push({
568
+ params: otherMatch.params,
569
+ pathLength: matchedPathname.length,
570
+ });
516
571
  }
517
572
  }
518
573
  }
574
+ // Sort ascending by path length so more-specific (longer) paths are applied
575
+ // last and their params take priority over less-specific ones.
576
+ matchingViews.sort((a, b) => a.pathLength - b.pathLength);
577
+ const params = {};
578
+ for (const view of matchingViews) {
579
+ Object.assign(params, view.params);
580
+ }
519
581
  return params;
520
582
  };
521
583
  /**
@@ -596,6 +658,18 @@ const resolveIndexRouteMatch = (viewItem, pathname, parentPath) => {
596
658
  class ReactRouterViewStack extends ViewStacks {
597
659
  constructor() {
598
660
  super();
661
+ /**
662
+ * Stores the computed parent path for each outlet.
663
+ * Used by findViewItemByPath to correctly evaluate index route matches
664
+ * without requiring the outlet's React element or route children.
665
+ */
666
+ this.outletParentPaths = new Map();
667
+ /**
668
+ * Stores the computed mount path for each outlet.
669
+ * Fed back into computeParentPath on subsequent calls to stabilize
670
+ * the parent path computation across navigations (mirrors StackManager.outletMountPath).
671
+ */
672
+ this.outletMountPaths = new Map();
599
673
  /**
600
674
  * Creates a new view item for the given outlet and react route element.
601
675
  * Associates route props with the matched route path for further lookups.
@@ -639,7 +713,7 @@ class ReactRouterViewStack extends ViewStacks {
639
713
  if (hasParams) {
640
714
  if (isWildcard) {
641
715
  const existingPathnameBase = (_f = (_e = v.routeData) === null || _e === void 0 ? void 0 : _e.match) === null || _f === void 0 ? void 0 : _f.pathnameBase;
642
- const newMatch = matchComponent$1(reactElement, routeInfo.pathname, false);
716
+ const newMatch = matchComponent$1(reactElement, routeInfo.pathname, false, this.outletParentPaths.get(outletId));
643
717
  const newPathnameBase = newMatch === null || newMatch === void 0 ? void 0 : newMatch.pathnameBase;
644
718
  if (existingPathnameBase !== newPathnameBase) {
645
719
  return false;
@@ -665,7 +739,7 @@ class ReactRouterViewStack extends ViewStacks {
665
739
  existingViewItem.reactElement = reactElement;
666
740
  existingViewItem.mount = true;
667
741
  existingViewItem.ionPageElement = page || existingViewItem.ionPageElement;
668
- const updatedMatch = matchComponent$1(reactElement, routeInfo.pathname, false) ||
742
+ const updatedMatch = matchComponent$1(reactElement, routeInfo.pathname, false, this.outletParentPaths.get(outletId)) ||
669
743
  ((_a = existingViewItem.routeData) === null || _a === void 0 ? void 0 : _a.match) ||
670
744
  createDefaultMatch(routeInfo.pathname, reactElement.props);
671
745
  existingViewItem.routeData = {
@@ -687,7 +761,7 @@ class ReactRouterViewStack extends ViewStacks {
687
761
  if (reactElement.type === IonRoute) {
688
762
  viewItem.disableIonPageManagement = reactElement.props.disableIonPageManagement;
689
763
  }
690
- const initialMatch = matchComponent$1(reactElement, routeInfo.pathname, true) ||
764
+ const initialMatch = matchComponent$1(reactElement, routeInfo.pathname, true, this.outletParentPaths.get(outletId)) ||
691
765
  createDefaultMatch(routeInfo.pathname, reactElement.props);
692
766
  viewItem.routeData = {
693
767
  match: initialMatch,
@@ -704,10 +778,10 @@ class ReactRouterViewStack extends ViewStacks {
704
778
  * - Wraps the route element in <Routes> to support nested routing and ensure remounting
705
779
  * - Adds a unique key to <Routes> so React Router remounts routes when switching
706
780
  */
707
- this.renderViewItem = (viewItem, routeInfo, parentPath) => {
781
+ this.renderViewItem = (viewItem, routeInfo, parentPath, reRender) => {
708
782
  var _a, _b, _c, _d, _e, _f, _g, _h;
709
783
  const routePath = viewItem.reactElement.props.path || '';
710
- let match = matchComponent$1(viewItem.reactElement, routeInfo.pathname);
784
+ let match = matchComponent$1(viewItem.reactElement, routeInfo.pathname, false, parentPath);
711
785
  if (!match) {
712
786
  const indexMatch = resolveIndexRouteMatch(viewItem, routeInfo.pathname, parentPath);
713
787
  if (indexMatch) {
@@ -742,6 +816,7 @@ class ReactRouterViewStack extends ViewStacks {
742
816
  // This ensures the redirect completes before removal
743
817
  setTimeout(() => {
744
818
  this.remove(viewItem);
819
+ reRender === null || reRender === void 0 ? void 0 : reRender();
745
820
  }, NAVIGATE_REDIRECT_DELAY_MS);
746
821
  }
747
822
  }
@@ -765,6 +840,7 @@ class ReactRouterViewStack extends ViewStacks {
765
840
  const stillNotNeeded = !viewItem.mount && !viewItem.ionPageElement;
766
841
  if (stillNotNeeded) {
767
842
  this.remove(viewItem);
843
+ reRender === null || reRender === void 0 ? void 0 : reRender();
768
844
  }
769
845
  }, VIEW_CLEANUP_DELAY_MS);
770
846
  }
@@ -775,25 +851,71 @@ class ReactRouterViewStack extends ViewStacks {
775
851
  }
776
852
  // Reactivate view if it matches but was previously deactivated
777
853
  // Don't reactivate if this is a parameterized route navigating to a different path instance
778
- if (match && !viewItem.mount && !shouldSkipForDifferentParam) {
854
+ // Don't reactivate catch-all wildcard routes — they are created fresh by createViewItem
855
+ const isCatchAllWildcard = routePath === '*' || routePath === '/*';
856
+ if (match && !viewItem.mount && !shouldSkipForDifferentParam && !isCatchAllWildcard) {
779
857
  viewItem.mount = true;
780
858
  viewItem.routeData.match = match;
781
859
  }
782
- // Deactivate wildcard routes and catch-all routes (empty path) when we have specific route matches
783
- // This prevents "Not found" or fallback pages from showing alongside valid routes
860
+ // Deactivate wildcard (catch-all) and empty-path (default) routes when a more-specific route matches.
861
+ // This prevents "Not found" or fallback pages from showing alongside valid routes.
784
862
  if (routePath === '*' || routePath === '') {
785
863
  // Check if any other view in this outlet has a match for the current route
786
- const hasSpecificMatch = this.getViewItemsForOutlet(viewItem.outletId).some((v) => {
864
+ const outletViews = this.getViewItemsForOutlet(viewItem.outletId);
865
+ // When parent path context is available, compute the relative pathname once
866
+ // outside the loop since both routeInfo.pathname and parentPath are invariant.
867
+ const relativePathname = parentPath
868
+ ? computeRelativeToParent(routeInfo.pathname, parentPath)
869
+ : null;
870
+ let hasSpecificMatch = outletViews.some((v) => {
787
871
  var _a, _b;
788
872
  if (v.id === viewItem.id)
789
873
  return false; // Skip self
790
874
  const vRoutePath = ((_b = (_a = v.reactElement) === null || _a === void 0 ? void 0 : _a.props) === null || _b === void 0 ? void 0 : _b.path) || '';
791
875
  if (vRoutePath === '*' || vRoutePath === '')
792
876
  return false; // Skip other wildcard/empty routes
793
- // Check if this view item would match the current route
877
+ // When parent path context is available and the route is relative, use
878
+ // parent-path-aware matching. This avoids false positives from
879
+ // derivePathnameToMatch's tail-slice heuristic, which can incorrectly
880
+ // match route literals that appear at the wrong position in the pathname.
881
+ // Example: pathname /parent/extra/details/99 with route details/:id —
882
+ // the tail-slice extracts ["details","99"] producing a false match.
883
+ if (parentPath && vRoutePath && !vRoutePath.startsWith('/')) {
884
+ if (relativePathname === null) {
885
+ return false; // Pathname is outside this outlet's parent scope
886
+ }
887
+ return !!matchPath({
888
+ pathname: relativePathname,
889
+ componentProps: v.reactElement.props,
890
+ });
891
+ }
892
+ // Fallback to matchComponent when no parent path context is available
794
893
  const vMatch = v.reactElement ? matchComponent$1(v.reactElement, routeInfo.pathname) : null;
795
894
  return !!vMatch;
796
895
  });
896
+ // For catch-all * routes, also deactivate when the pathname matches the outlet's
897
+ // parent path exactly. This means there are no remaining segments for the wildcard
898
+ // to catch, so the empty-path or index route should handle it instead.
899
+ if (!hasSpecificMatch && routePath === '*') {
900
+ const outletParentPath = this.outletParentPaths.get(viewItem.outletId);
901
+ if (outletParentPath) {
902
+ const normalizedParent = normalizePathnameForComparison(outletParentPath);
903
+ const normalizedPathname = normalizePathnameForComparison(routeInfo.pathname);
904
+ if (normalizedPathname === normalizedParent) {
905
+ // Check if there's an empty-path or index view item that should handle this
906
+ const hasDefaultRoute = outletViews.some((v) => {
907
+ var _a, _b, _c, _d;
908
+ if (v.id === viewItem.id)
909
+ return false;
910
+ const vRoutePath = (_b = (_a = v.reactElement) === null || _a === void 0 ? void 0 : _a.props) === null || _b === void 0 ? void 0 : _b.path;
911
+ return vRoutePath === '' || vRoutePath === undefined || !!((_d = (_c = v.routeData) === null || _c === void 0 ? void 0 : _c.childProps) === null || _d === void 0 ? void 0 : _d.index);
912
+ });
913
+ if (hasDefaultRoute) {
914
+ hasSpecificMatch = true;
915
+ }
916
+ }
917
+ }
918
+ }
797
919
  if (hasSpecificMatch) {
798
920
  viewItem.mount = false;
799
921
  if (viewItem.ionPageElement) {
@@ -838,33 +960,50 @@ class ReactRouterViewStack extends ViewStacks {
838
960
  * 3. Returns a list of React components that will be rendered inside the outlet
839
961
  * Each view is wrapped in <ViewLifeCycleManager> to manage lifecycle and rendering
840
962
  */
841
- this.getChildrenToRender = (outletId, ionRouterOutlet, routeInfo) => {
963
+ this.getChildrenToRender = (outletId, ionRouterOutlet, routeInfo, reRender, parentPathnameBase) => {
842
964
  const viewItems = this.getViewItemsForOutlet(outletId);
843
- // Determine parentPath for nested outlets to properly evaluate index routes
965
+ // Seed the mount path from the parent route context if available.
966
+ // This provides the outlet's mount path immediately on first render,
967
+ // eliminating the need for heuristic-based discovery in computeParentPath.
968
+ if (parentPathnameBase && !this.outletMountPaths.has(outletId)) {
969
+ this.outletMountPaths.set(outletId, parentPathnameBase);
970
+ }
971
+ // Determine parentPath for outlets with relative or index routes.
972
+ // This populates outletParentPaths for findViewItemByPath's matchView
973
+ // and the catch-all deactivation logic in renderViewItem.
844
974
  let parentPath = undefined;
845
975
  try {
846
- // Only attempt parent path computation for non-root outlets
847
- // Root outlets have IDs like 'routerOutlet' or 'routerOutlet-2'
848
- const isRootOutlet = outletId.startsWith('routerOutlet');
849
- if (!isRootOutlet) {
850
- const routeChildren = extractRouteChildren(ionRouterOutlet.props.children);
851
- const { hasRelativeRoutes, hasIndexRoute, hasWildcardRoute } = analyzeRouteChildren(routeChildren);
852
- if (hasRelativeRoutes || hasIndexRoute) {
853
- const result = computeParentPath({
854
- currentPathname: routeInfo.pathname,
855
- outletMountPath: undefined,
856
- routeChildren,
857
- hasRelativeRoutes,
858
- hasIndexRoute,
859
- hasWildcardRoute,
860
- });
861
- parentPath = result.parentPath;
976
+ const routeChildren = extractRouteChildren(ionRouterOutlet.props.children);
977
+ const { hasRelativeRoutes, hasIndexRoute, hasWildcardRoute } = analyzeRouteChildren(routeChildren);
978
+ if (hasRelativeRoutes || hasIndexRoute) {
979
+ const result = computeParentPath({
980
+ currentPathname: routeInfo.pathname,
981
+ outletMountPath: this.outletMountPaths.get(outletId),
982
+ routeChildren,
983
+ hasRelativeRoutes,
984
+ hasIndexRoute,
985
+ hasWildcardRoute,
986
+ });
987
+ parentPath = result.parentPath;
988
+ // Persist the mount path for subsequent calls, mirroring StackManager.outletMountPath.
989
+ // Unlike outletParentPaths (cleared when parentPath is undefined), the mount path is
990
+ // intentionally sticky — it anchors the outlet's scope and is only removed in clear().
991
+ if (result.outletMountPath && !this.outletMountPaths.has(outletId)) {
992
+ this.outletMountPaths.set(outletId, result.outletMountPath);
862
993
  }
863
994
  }
864
995
  }
865
996
  catch (e) {
866
997
  // Non-fatal: if we fail to compute parentPath, fall back to previous behavior
867
998
  }
999
+ // Store the computed parentPath for use in findViewItemByPath.
1000
+ // Clear stale entries when parentPath is undefined (e.g., navigated out of scope).
1001
+ if (parentPath !== undefined) {
1002
+ this.outletParentPaths.set(outletId, parentPath);
1003
+ }
1004
+ else if (this.outletParentPaths.has(outletId)) {
1005
+ this.outletParentPaths.delete(outletId);
1006
+ }
868
1007
  // Sync child elements with stored viewItems (e.g. to reflect new props)
869
1008
  React.Children.forEach(ionRouterOutlet.props.children, (child) => {
870
1009
  // Ensure the child is a valid React element since we
@@ -908,7 +1047,7 @@ class ReactRouterViewStack extends ViewStacks {
908
1047
  const viewRoutePath = (_d = (_c = viewItem.reactElement) === null || _c === void 0 ? void 0 : _c.props) === null || _d === void 0 ? void 0 : _d.path;
909
1048
  if (viewRoutePath) {
910
1049
  // First try exact match using matchComponent
911
- const routeMatch = matchComponent$1(viewItem.reactElement, routeInfo.pathname);
1050
+ const routeMatch = matchComponent$1(viewItem.reactElement, routeInfo.pathname, false, parentPath);
912
1051
  if (routeMatch) {
913
1052
  // View matches current route, keep it
914
1053
  return true;
@@ -924,6 +1063,7 @@ class ReactRouterViewStack extends ViewStacks {
924
1063
  // View is outside current route hierarchy, remove it
925
1064
  setTimeout(() => {
926
1065
  this.remove(viewItem);
1066
+ reRender();
927
1067
  }, 0);
928
1068
  return false;
929
1069
  }
@@ -931,7 +1071,7 @@ class ReactRouterViewStack extends ViewStacks {
931
1071
  }
932
1072
  return true;
933
1073
  });
934
- const renderedItems = renderableViewItems.map((viewItem) => this.renderViewItem(viewItem, routeInfo, parentPath));
1074
+ const renderedItems = renderableViewItems.map((viewItem) => this.renderViewItem(viewItem, routeInfo, parentPath, reRender));
935
1075
  return renderedItems;
936
1076
  };
937
1077
  /**
@@ -991,6 +1131,14 @@ class ReactRouterViewStack extends ViewStacks {
991
1131
  super.add(viewItem);
992
1132
  this.cleanupStaleViewItems(viewItem.outletId);
993
1133
  };
1134
+ /**
1135
+ * Override clear to also clean up the stored parent path for the outlet.
1136
+ */
1137
+ this.clear = (outletId) => {
1138
+ this.outletParentPaths.delete(outletId);
1139
+ this.outletMountPaths.delete(outletId);
1140
+ return super.clear(outletId);
1141
+ };
994
1142
  /**
995
1143
  * Override remove
996
1144
  */
@@ -1006,6 +1154,8 @@ class ReactRouterViewStack extends ViewStacks {
1006
1154
  let viewItem;
1007
1155
  let match = null;
1008
1156
  let viewStack;
1157
+ // Capture stored parent paths for use in nested matchView/matchDefaultRoute functions
1158
+ const storedParentPaths = this.outletParentPaths;
1009
1159
  if (outletId) {
1010
1160
  viewStack = sortViewsBySpecificity(this.getViewItemsForOutlet(outletId));
1011
1161
  viewStack.some(matchView);
@@ -1033,16 +1183,36 @@ class ReactRouterViewStack extends ViewStacks {
1033
1183
  if (mustBeIonRoute && !v.ionRoute)
1034
1184
  return false;
1035
1185
  const viewItemPath = v.routeData.childProps.path || '';
1186
+ // Skip unmounted catch-all wildcard views. After back navigation unmounts
1187
+ // a wildcard view, it should not be reused for subsequent navigations.
1188
+ // A fresh wildcard view will be created by createViewItem when needed.
1189
+ if ((viewItemPath === '*' || viewItemPath === '/*') && !v.mount)
1190
+ return false;
1036
1191
  const isIndexRoute = !!v.routeData.childProps.index;
1037
1192
  const previousMatch = (_a = v.routeData) === null || _a === void 0 ? void 0 : _a.match;
1038
- const result = v.reactElement ? matchComponent$1(v.reactElement, pathname) : null;
1193
+ const outletParentPath = storedParentPaths.get(v.outletId);
1194
+ const result = v.reactElement ? matchComponent$1(v.reactElement, pathname, false, outletParentPath) : null;
1039
1195
  if (!result) {
1040
- const indexMatch = resolveIndexRouteMatch(v, pathname, undefined);
1196
+ const indexMatch = resolveIndexRouteMatch(v, pathname, outletParentPath);
1041
1197
  if (indexMatch) {
1042
1198
  match = indexMatch;
1043
1199
  viewItem = v;
1044
1200
  return true;
1045
1201
  }
1202
+ // Empty path routes (path="") should match when the pathname matches the
1203
+ // outlet's parent path exactly (no remaining segments). matchComponent doesn't
1204
+ // handle this because it lacks parent path context. Without this check, a
1205
+ // catch-all * view item (which matches any pathname) would be incorrectly
1206
+ // returned instead of the empty path route on back navigation.
1207
+ if (viewItemPath === '' && !isIndexRoute && outletParentPath) {
1208
+ const normalizedParent = normalizePathnameForComparison(outletParentPath);
1209
+ const normalizedPathname = normalizePathnameForComparison(pathname);
1210
+ if (normalizedPathname === normalizedParent) {
1211
+ match = createDefaultMatch(pathname, v.routeData.childProps);
1212
+ viewItem = v;
1213
+ return true;
1214
+ }
1215
+ }
1046
1216
  }
1047
1217
  if (result) {
1048
1218
  const hasParams = result.params && Object.keys(result.params).length > 0;
@@ -1099,7 +1269,8 @@ class ReactRouterViewStack extends ViewStacks {
1099
1269
  const isDefaultRoute = childProps.path === undefined || childProps.path === '';
1100
1270
  const isIndexRoute = !!childProps.index;
1101
1271
  if (isIndexRoute) {
1102
- const indexMatch = resolveIndexRouteMatch(v, pathname, undefined);
1272
+ const outletParentPath = storedParentPaths.get(v.outletId);
1273
+ const indexMatch = resolveIndexRouteMatch(v, pathname, outletParentPath);
1103
1274
  if (indexMatch) {
1104
1275
  match = indexMatch;
1105
1276
  viewItem = v;
@@ -1136,11 +1307,22 @@ class ReactRouterViewStack extends ViewStacks {
1136
1307
  /**
1137
1308
  * Utility to apply matchPath to a React element and return its match state.
1138
1309
  */
1139
- function matchComponent$1(node, pathname, allowFallback = false) {
1310
+ function matchComponent$1(node, pathname, allowFallback = false, parentPath) {
1140
1311
  var _a;
1141
1312
  const routeProps = (_a = node === null || node === void 0 ? void 0 : node.props) !== null && _a !== void 0 ? _a : {};
1142
1313
  const routePath = routeProps.path;
1143
- const pathnameToMatch = derivePathnameToMatch(pathname, routePath);
1314
+ let pathnameToMatch;
1315
+ if (parentPath && routePath && !routePath.startsWith('/')) {
1316
+ // When parent path is known, compute exact relative pathname
1317
+ // instead of using the tail-slice heuristic
1318
+ const relative = pathname.startsWith(parentPath)
1319
+ ? pathname.slice(parentPath.length).replace(/^\//, '')
1320
+ : pathname;
1321
+ pathnameToMatch = relative;
1322
+ }
1323
+ else {
1324
+ pathnameToMatch = derivePathnameToMatch(pathname, routePath);
1325
+ }
1144
1326
  const match = matchPath({
1145
1327
  pathname: pathnameToMatch,
1146
1328
  componentProps: routeProps,
@@ -1191,11 +1373,13 @@ function clonePageElement(leavingViewHtml) {
1191
1373
  */
1192
1374
  const VIEW_UNMOUNT_DELAY_MS = 250;
1193
1375
  /**
1194
- * Delay in milliseconds to wait for an IonPage element to be mounted before
1195
- * proceeding with a page transition.
1376
+ * Delay (ms) to wait for an IonPage to mount before proceeding with a
1377
+ * page transition. Only container routes (nested outlets with no direct
1378
+ * IonPage) actually hit this timeout; normal routes clear it early via
1379
+ * registerIonPage, so a larger value here doesn't affect the happy path.
1196
1380
  */
1197
- const ION_PAGE_WAIT_TIMEOUT_MS = 50;
1198
- const isViewVisible = (el) => !el.classList.contains('ion-page-invisible') && !el.classList.contains('ion-page-hidden') && el.style.display !== 'none';
1381
+ const ION_PAGE_WAIT_TIMEOUT_MS = 300;
1382
+ const isViewVisible = (el) => !el.classList.contains('ion-page-invisible') && !el.classList.contains('ion-page-hidden') && el.style.visibility !== 'hidden';
1199
1383
  const hideIonPageElement = (element) => {
1200
1384
  if (element) {
1201
1385
  element.classList.add('ion-page-hidden');
@@ -1204,7 +1388,7 @@ const hideIonPageElement = (element) => {
1204
1388
  };
1205
1389
  const showIonPageElement = (element) => {
1206
1390
  if (element) {
1207
- element.style.removeProperty('display');
1391
+ element.style.removeProperty('visibility');
1208
1392
  element.classList.remove('ion-page-hidden');
1209
1393
  element.removeAttribute('aria-hidden');
1210
1394
  }
@@ -1218,7 +1402,16 @@ class StackManager extends React.PureComponent {
1218
1402
  };
1219
1403
  this.pendingPageTransition = false;
1220
1404
  this.waitingForIonPage = false;
1405
+ /** Tracks whether the component is mounted to guard async transition paths. */
1406
+ this._isMounted = false;
1407
+ /** In-flight requestAnimationFrame IDs from transitionPage, cancelled on unmount. */
1408
+ this.transitionRafIds = [];
1221
1409
  this.outletMountPath = undefined;
1410
+ /**
1411
+ * Whether this outlet is at the root level (no parent route matches).
1412
+ * Derived from UNSAFE_RouteContext in render() — empty matches means root.
1413
+ */
1414
+ this.isRootOutlet = true;
1222
1415
  this.registerIonPage = this.registerIonPage.bind(this);
1223
1416
  this.transitionPage = this.transitionPage.bind(this);
1224
1417
  this.handlePageTransition = this.handlePageTransition.bind(this);
@@ -1228,20 +1421,30 @@ class StackManager extends React.PureComponent {
1228
1421
  }
1229
1422
  /**
1230
1423
  * Determines the parent path for nested routing in React Router 6.
1231
- * Priority: specific routes > wildcard routes > index routes (only at mount point)
1424
+ *
1425
+ * When the mount path is known (seeded from UNSAFE_RouteContext), returns
1426
+ * it directly — no iterative discovery needed. The computeParentPath
1427
+ * fallback only runs for root outlets where RouteContext doesn't provide
1428
+ * a parent match.
1232
1429
  */
1233
1430
  getParentPath() {
1234
1431
  const currentPathname = this.props.routeInfo.pathname;
1235
- // Prevent out-of-scope outlets from adopting unrelated routes
1236
- if (this.outletMountPath && !currentPathname.startsWith(this.outletMountPath)) {
1432
+ // Prevent out-of-scope outlets from adopting unrelated routes.
1433
+ // Uses segment-aware comparison: /tabs-secondary must NOT match /tabs scope.
1434
+ if (this.outletMountPath && !isPathnameInScope(currentPathname, this.outletMountPath)) {
1237
1435
  return undefined;
1238
1436
  }
1437
+ // Fast path: mount path is known from RouteContext. The parent path IS the
1438
+ // mount path — no need to run the iterative computeParentPath algorithm.
1439
+ if (this.outletMountPath && !this.isRootOutlet) {
1440
+ return this.outletMountPath;
1441
+ }
1442
+ // Fallback: root outlet or mount path not yet seeded. Run the full
1443
+ // computeParentPath algorithm to discover the parent depth.
1239
1444
  if (this.ionRouterOutlet) {
1240
1445
  const routeChildren = extractRouteChildren(this.ionRouterOutlet.props.children);
1241
1446
  const { hasRelativeRoutes, hasIndexRoute, hasWildcardRoute } = analyzeRouteChildren(routeChildren);
1242
- const isRootOutlet = this.id.startsWith('routerOutlet');
1243
- const needsParentPath = !isRootOutlet || hasRelativeRoutes || hasIndexRoute;
1244
- if (needsParentPath) {
1447
+ if (!this.isRootOutlet || hasRelativeRoutes || hasIndexRoute) {
1245
1448
  const result = computeParentPath({
1246
1449
  currentPathname,
1247
1450
  outletMountPath: this.outletMountPath,
@@ -1350,12 +1553,11 @@ class StackManager extends React.PureComponent {
1350
1553
  */
1351
1554
  handleOutOfContextNestedOutlet(parentPath, leavingViewItem) {
1352
1555
  var _a;
1353
- const isRootOutlet = this.id.startsWith('routerOutlet');
1354
- if (isRootOutlet || parentPath !== undefined || !this.ionRouterOutlet) {
1556
+ if (this.isRootOutlet || parentPath !== undefined || !this.ionRouterOutlet) {
1355
1557
  return false;
1356
1558
  }
1357
1559
  const routesChildren = (_a = getRoutesChildren(this.ionRouterOutlet.props.children)) !== null && _a !== void 0 ? _a : this.ionRouterOutlet.props.children;
1358
- const routeChildren = React.Children.toArray(routesChildren).filter((child) => React.isValidElement(child) && child.type === Route);
1560
+ const routeChildren = React.Children.toArray(routesChildren).filter((child) => React.isValidElement(child) && (child.type === Route || child.type === IonRoute));
1359
1561
  const hasRelativeRoutes = routeChildren.some((route) => {
1360
1562
  const path = route.props.path;
1361
1563
  return path && !path.startsWith('/') && path !== '*';
@@ -1374,8 +1576,7 @@ class StackManager extends React.PureComponent {
1374
1576
  * Handles nested outlet with no matching route. Returns true to abort.
1375
1577
  */
1376
1578
  handleNoMatchingRoute(enteringRoute, enteringViewItem, leavingViewItem) {
1377
- const isRootOutlet = this.id.startsWith('routerOutlet');
1378
- if (isRootOutlet || enteringRoute || enteringViewItem) {
1579
+ if (this.isRootOutlet || enteringRoute || enteringViewItem) {
1379
1580
  return false;
1380
1581
  }
1381
1582
  hideIonPageElement(leavingViewItem === null || leavingViewItem === void 0 ? void 0 : leavingViewItem.ionPageElement);
@@ -1397,7 +1598,7 @@ class StackManager extends React.PureComponent {
1397
1598
  // When entering === leaving, the view is already visible - skip transition to prevent flash
1398
1599
  if (enteringViewItem === leavingViewItem) {
1399
1600
  if (isParameterizedRoute || isWildcardContainerRoute) {
1400
- const updatedMatch = matchComponent(enteringViewItem.reactElement, routeInfo.pathname, true);
1601
+ const updatedMatch = matchComponent(enteringViewItem.reactElement, routeInfo.pathname, true, this.outletMountPath);
1401
1602
  if (updatedMatch) {
1402
1603
  enteringViewItem.routeData.match = updatedMatch;
1403
1604
  }
@@ -1420,7 +1621,7 @@ class StackManager extends React.PureComponent {
1420
1621
  const currentInContainer = routeInfo.pathname.startsWith(containerBase + '/') || routeInfo.pathname === containerBase;
1421
1622
  const previousInContainer = routeInfo.lastPathname.startsWith(containerBase + '/') || routeInfo.lastPathname === containerBase;
1422
1623
  if (currentInContainer && previousInContainer) {
1423
- const updatedMatch = matchComponent(enteringViewItem.reactElement, routeInfo.pathname, true);
1624
+ const updatedMatch = matchComponent(enteringViewItem.reactElement, routeInfo.pathname, true, this.outletMountPath);
1424
1625
  if (updatedMatch) {
1425
1626
  enteringViewItem.routeData.match = updatedMatch;
1426
1627
  }
@@ -1589,17 +1790,19 @@ class StackManager extends React.PureComponent {
1589
1790
  * nested scrollbars (each page has its own IonContent). Top-level outlets
1590
1791
  * are unaffected and animate normally.
1591
1792
  *
1592
- * Uses inline display:none rather than ion-page-hidden class because core's
1593
- * beforeTransition() removes ion-page-hidden via setPageHidden().
1594
- * Inline display:none survives that removal, keeping the page hidden
1595
- * until React unmounts it after ionViewDidLeave fires.
1793
+ * Uses inline visibility:hidden rather than ion-page-hidden class because
1794
+ * core's beforeTransition() removes ion-page-hidden via setPageHidden().
1795
+ * Inline visibility:hidden survives that removal, keeping the page hidden
1796
+ * until React unmounts it after ionViewDidLeave fires. Unlike display:none,
1797
+ * visibility:hidden preserves element geometry so commit() animations
1798
+ * can resolve normally.
1596
1799
  */
1597
1800
  applySkipAnimationIfNeeded(enteringViewItem, leavingViewItem) {
1598
1801
  var _a;
1599
1802
  const isNestedOutlet = !!((_a = this.routerOutletElement) === null || _a === void 0 ? void 0 : _a.closest('.ion-page'));
1600
1803
  const shouldSkip = isNestedOutlet && !!leavingViewItem && enteringViewItem !== leavingViewItem;
1601
1804
  if (shouldSkip && (leavingViewItem === null || leavingViewItem === void 0 ? void 0 : leavingViewItem.ionPageElement)) {
1602
- leavingViewItem.ionPageElement.style.setProperty('display', 'none');
1805
+ leavingViewItem.ionPageElement.style.setProperty('visibility', 'hidden');
1603
1806
  leavingViewItem.ionPageElement.setAttribute('aria-hidden', 'true');
1604
1807
  }
1605
1808
  return shouldSkip;
@@ -1676,6 +1879,20 @@ class StackManager extends React.PureComponent {
1676
1879
  }
1677
1880
  });
1678
1881
  this.forceUpdate();
1882
+ // Safety net: after forceUpdate triggers a React render cycle, check if
1883
+ // any pages in this outlet are stuck with ion-page-invisible. This can
1884
+ // happen when view lookup fails (e.g., wildcard-to-index transitions
1885
+ // where the view item gets corrupted). The forceUpdate above causes
1886
+ // React to render the correct component, but ion-page-invisible may
1887
+ // persist if no transition runs for that page.
1888
+ setTimeout(() => {
1889
+ if (!this._isMounted || !this.routerOutletElement)
1890
+ return;
1891
+ const stuckPages = this.routerOutletElement.querySelectorAll(':scope > .ion-page-invisible');
1892
+ stuckPages.forEach((page) => {
1893
+ page.classList.remove('ion-page-invisible');
1894
+ });
1895
+ }, ION_PAGE_WAIT_TIMEOUT_MS);
1679
1896
  }
1680
1897
  }, ION_PAGE_WAIT_TIMEOUT_MS);
1681
1898
  this.forceUpdate();
@@ -1691,6 +1908,7 @@ class StackManager extends React.PureComponent {
1691
1908
  : { pathname: routeInfo.pushedByRoute || '' };
1692
1909
  }
1693
1910
  componentDidMount() {
1911
+ this._isMounted = true;
1694
1912
  if (this.clearOutletTimeout) {
1695
1913
  /**
1696
1914
  * The clearOutlet integration with React Router is a bit hacky.
@@ -1721,6 +1939,17 @@ class StackManager extends React.PureComponent {
1721
1939
  }
1722
1940
  }
1723
1941
  componentWillUnmount() {
1942
+ this._isMounted = false;
1943
+ // Cancel any in-flight transition rAFs
1944
+ for (const id of this.transitionRafIds) {
1945
+ cancelAnimationFrame(id);
1946
+ }
1947
+ this.transitionRafIds = [];
1948
+ // Disconnect any in-flight MutationObserver from waitForComponentsReady
1949
+ if (this.transitionObserver) {
1950
+ this.transitionObserver.disconnect();
1951
+ this.transitionObserver = undefined;
1952
+ }
1724
1953
  if (this.ionPageWaitTimeout) {
1725
1954
  clearTimeout(this.ionPageWaitTimeout);
1726
1955
  this.ionPageWaitTimeout = undefined;
@@ -2052,13 +2281,25 @@ class StackManager extends React.PureComponent {
2052
2281
  enteringEl.classList.add('ion-page-invisible');
2053
2282
  }
2054
2283
  }
2055
- await routerOutlet.commit(enteringEl, leavingEl, {
2056
- duration: skipTransition || skipAnimation || directionToUse === undefined ? 0 : undefined,
2284
+ const commitDuration = skipTransition || skipAnimation || directionToUse === undefined ? 0 : undefined;
2285
+ // Race commit against a timeout to recover from hangs
2286
+ const commitPromise = routerOutlet.commit(enteringEl, leavingEl, {
2287
+ duration: commitDuration,
2057
2288
  direction: directionToUse,
2058
2289
  showGoBack: !!routeInfo.pushedByRoute,
2059
2290
  progressAnimation,
2060
2291
  animationBuilder: routeInfo.routeAnimation,
2061
2292
  });
2293
+ const timeoutMs = 5000;
2294
+ const timeoutPromise = new Promise((resolve) => setTimeout(() => resolve('timeout'), timeoutMs));
2295
+ const result = await Promise.race([commitPromise.then(() => 'done'), timeoutPromise]);
2296
+ if (result === 'timeout') {
2297
+ // Force entering page visible even though commit hung
2298
+ enteringEl.classList.remove('ion-page-invisible');
2299
+ }
2300
+ if (!progressAnimation) {
2301
+ enteringEl.classList.remove('ion-page-invisible');
2302
+ }
2062
2303
  };
2063
2304
  const routerOutlet = this.routerOutletElement;
2064
2305
  const routeInfoFallbackDirection = routeInfo.routeDirection === 'none' || routeInfo.routeDirection === 'root' ? undefined : routeInfo.routeDirection;
@@ -2066,7 +2307,7 @@ class StackManager extends React.PureComponent {
2066
2307
  if (enteringViewItem && enteringViewItem.ionPageElement && this.routerOutletElement) {
2067
2308
  if (leavingViewItem && leavingViewItem.ionPageElement && enteringViewItem === leavingViewItem) {
2068
2309
  // Clone page for same-view transitions (e.g., /user/1 → /user/2)
2069
- const match = matchComponent(leavingViewItem.reactElement, routeInfo.pathname);
2310
+ const match = matchComponent(leavingViewItem.reactElement, routeInfo.pathname, undefined, this.outletMountPath);
2070
2311
  if (match) {
2071
2312
  const newLeavingElement = clonePageElement(leavingViewItem.ionPageElement.outerHTML);
2072
2313
  if (newLeavingElement) {
@@ -2087,21 +2328,21 @@ class StackManager extends React.PureComponent {
2087
2328
  const isNonAnimatedTransition = directionToUse === undefined && !progressAnimation;
2088
2329
  if (isNonAnimatedTransition && leavingEl) {
2089
2330
  /**
2090
- * Flicker prevention for non-animated transitions:
2091
- * Skip commit() entirely for simple visibility swaps (like tab switches).
2092
- * commit() runs animation logic that can cause intermediate paints even with
2093
- * duration: 0. Instead, we directly swap visibility classes and wait for
2094
- * components to be ready before showing the entering element.
2331
+ * Skip commit() for non-animated transitions (like tab switches).
2332
+ * commit() runs animation logic that can cause intermediate paints
2333
+ * even with duration: 0. Instead, swap visibility synchronously.
2334
+ *
2335
+ * Synchronous DOM class changes are batched into a single browser
2336
+ * paint, so there's no gap frame where neither page is visible and
2337
+ * no overlap frame where both pages are visible.
2095
2338
  */
2096
2339
  const enteringEl = enteringViewItem.ionPageElement;
2097
2340
  // Ensure entering element has proper base classes
2098
2341
  enteringEl.classList.add('ion-page');
2099
- // Only add ion-page-invisible if not already visible (e.g., tab switches)
2100
- if (!isViewVisible(enteringEl)) {
2101
- enteringEl.classList.add('ion-page-invisible');
2102
- }
2103
- enteringEl.classList.remove('ion-page-hidden');
2104
- enteringEl.removeAttribute('aria-hidden');
2342
+ // Clear ALL hidden state from entering element. showIonPageElement
2343
+ // removes visibility:hidden (from applySkipAnimationIfNeeded),
2344
+ // ion-page-hidden, and aria-hidden in one call.
2345
+ showIonPageElement(enteringEl);
2105
2346
  // Handle can-go-back class since we're skipping commit() which normally sets this
2106
2347
  if (routeInfo.pushedByRoute) {
2107
2348
  enteringEl.classList.add('can-go-back');
@@ -2131,9 +2372,17 @@ class StackManager extends React.PureComponent {
2131
2372
  if (!resolved && checkReady()) {
2132
2373
  resolved = true;
2133
2374
  observer.disconnect();
2375
+ if (this.transitionObserver === observer) {
2376
+ this.transitionObserver = undefined;
2377
+ }
2134
2378
  resolve();
2135
2379
  }
2136
2380
  });
2381
+ // Disconnect any previous observer before tracking the new one
2382
+ if (this.transitionObserver) {
2383
+ this.transitionObserver.disconnect();
2384
+ }
2385
+ this.transitionObserver = observer;
2137
2386
  observer.observe(enteringEl, {
2138
2387
  subtree: true,
2139
2388
  attributes: true,
@@ -2143,28 +2392,25 @@ class StackManager extends React.PureComponent {
2143
2392
  if (!resolved) {
2144
2393
  resolved = true;
2145
2394
  observer.disconnect();
2395
+ if (this.transitionObserver === observer) {
2396
+ this.transitionObserver = undefined;
2397
+ }
2146
2398
  resolve();
2147
2399
  }
2148
2400
  }, 100);
2149
2401
  });
2150
2402
  };
2151
2403
  await waitForComponentsReady();
2152
- // Swap visibility in sync with browser's render cycle
2153
- await new Promise((resolve) => {
2154
- requestAnimationFrame(() => {
2155
- enteringEl.classList.remove('ion-page-invisible');
2156
- // Second rAF ensures entering is painted before hiding leaving
2157
- requestAnimationFrame(() => {
2158
- leavingEl.classList.add('ion-page-hidden');
2159
- leavingEl.setAttribute('aria-hidden', 'true');
2160
- resolve();
2161
- });
2162
- });
2163
- });
2404
+ // Bail out if the component unmounted during waitForComponentsReady
2405
+ if (!this._isMounted)
2406
+ return;
2407
+ // Swap visibility synchronously - show entering, hide leaving
2408
+ enteringEl.classList.remove('ion-page-invisible');
2409
+ leavingEl.classList.add('ion-page-hidden');
2410
+ leavingEl.setAttribute('aria-hidden', 'true');
2164
2411
  }
2165
2412
  else {
2166
2413
  await runCommit(enteringViewItem.ionPageElement, leavingEl);
2167
- // For animated transitions, hide leaving element after commit completes
2168
2414
  if (leavingEl && !progressAnimation) {
2169
2415
  leavingEl.classList.add('ion-page-hidden');
2170
2416
  leavingEl.setAttribute('aria-hidden', 'true');
@@ -2178,33 +2424,89 @@ class StackManager extends React.PureComponent {
2178
2424
  const ionRouterOutlet = React.Children.only(children);
2179
2425
  // Store reference for use in getParentPath() and handlePageTransition()
2180
2426
  this.ionRouterOutlet = ionRouterOutlet;
2181
- const components = this.context.getChildrenToRender(this.id, this.ionRouterOutlet, this.props.routeInfo, () => {
2182
- // Callback triggers re-render when view items are modified during getChildrenToRender
2183
- this.forceUpdate();
2184
- });
2185
- return (React.createElement(StackContext.Provider, { value: this.stackContextValue }, React.cloneElement(ionRouterOutlet, {
2186
- ref: (node) => {
2187
- if (ionRouterOutlet.props.setRef) {
2188
- // Needed to handle external refs from devs.
2189
- ionRouterOutlet.props.setRef(node);
2190
- }
2191
- if (ionRouterOutlet.props.forwardedRef) {
2192
- // Needed to handle external refs from devs.
2193
- ionRouterOutlet.props.forwardedRef.current = node;
2194
- }
2195
- this.routerOutletElement = node;
2196
- const { ref } = ionRouterOutlet;
2197
- // Check for legacy refs.
2198
- if (typeof ref === 'function') {
2199
- ref(node);
2200
- }
2201
- },
2202
- }, components)));
2427
+ return (React.createElement(UNSAFE_RouteContext.Consumer, null, (parentContext) => {
2428
+ // Derive the outlet's mount path from React Router's matched route context.
2429
+ // This eliminates the need for heuristic-based mount path discovery in
2430
+ // computeParentPath, since React Router already knows the matched base path.
2431
+ const parentMatches = parentContext === null || parentContext === void 0 ? void 0 : parentContext.matches;
2432
+ const parentPathnameBase = parentMatches && parentMatches.length > 0
2433
+ ? parentMatches[parentMatches.length - 1].pathnameBase
2434
+ : undefined;
2435
+ // Derive isRootOutlet from RouteContext: empty matches means root.
2436
+ this.isRootOutlet = !parentMatches || parentMatches.length === 0;
2437
+ // Seed StackManager's mount path from the parent route context
2438
+ if (parentPathnameBase && !this.outletMountPath) {
2439
+ this.outletMountPath = parentPathnameBase;
2440
+ }
2441
+ const components = this.context.getChildrenToRender(this.id, this.ionRouterOutlet, this.props.routeInfo, () => {
2442
+ // Callback triggers re-render when view items are modified during getChildrenToRender
2443
+ this.forceUpdate();
2444
+ }, parentPathnameBase);
2445
+ return (React.createElement(StackContext.Provider, { value: this.stackContextValue }, React.cloneElement(ionRouterOutlet, {
2446
+ ref: (node) => {
2447
+ if (ionRouterOutlet.props.setRef) {
2448
+ // Needed to handle external refs from devs.
2449
+ ionRouterOutlet.props.setRef(node);
2450
+ }
2451
+ if (ionRouterOutlet.props.forwardedRef) {
2452
+ // Needed to handle external refs from devs.
2453
+ ionRouterOutlet.props.forwardedRef.current = node;
2454
+ }
2455
+ this.routerOutletElement = node;
2456
+ const { ref } = ionRouterOutlet;
2457
+ // Check for legacy refs.
2458
+ if (typeof ref === 'function') {
2459
+ ref(node);
2460
+ }
2461
+ },
2462
+ }, components)));
2463
+ }));
2203
2464
  }
2204
2465
  static get contextType() {
2205
2466
  return RouteManagerContext;
2206
2467
  }
2207
2468
  }
2469
+ /**
2470
+ * Converts React Route elements to RouteObject format for use with matchRoutes().
2471
+ * Filters out pathless routes (which are handled by fallback logic separately).
2472
+ *
2473
+ * When a basename is provided, absolute route paths are relativized by stripping
2474
+ * the basename prefix. This is necessary because matchRoutes() strips the basename
2475
+ * from the LOCATION pathname but not from route paths — absolute paths must be
2476
+ * made relative to the basename for matching to work correctly.
2477
+ *
2478
+ * @param routeChildren The flat array of Route/IonRoute elements from the outlet.
2479
+ * @param basename The resolved parent path (without trailing slash or `/*`) used to relativize absolute paths.
2480
+ */
2481
+ function routeElementsToRouteObjects(routeChildren, basename) {
2482
+ return routeChildren
2483
+ .filter((child) => child.props.path != null || child.props.index)
2484
+ .map((child) => {
2485
+ const handle = { _element: child };
2486
+ let path = child.props.path;
2487
+ // Relativize absolute paths by stripping the basename prefix
2488
+ if (path && path.startsWith('/') && basename) {
2489
+ if (path === basename) {
2490
+ path = '';
2491
+ }
2492
+ else if (path.startsWith(basename + '/')) {
2493
+ path = path.slice(basename.length + 1);
2494
+ }
2495
+ }
2496
+ if (child.props.index) {
2497
+ return {
2498
+ index: true,
2499
+ handle,
2500
+ caseSensitive: child.props.caseSensitive || undefined,
2501
+ };
2502
+ }
2503
+ return {
2504
+ path,
2505
+ handle,
2506
+ caseSensitive: child.props.caseSensitive || undefined,
2507
+ };
2508
+ });
2509
+ }
2208
2510
  /**
2209
2511
  * Finds the `<Route />` node matching the current route info.
2210
2512
  * If no `<Route />` can be matched, a fallback node is returned.
@@ -2215,137 +2517,62 @@ class StackManager extends React.PureComponent {
2215
2517
  * @param parentPath The parent path that was matched by the parent outlet (for nested routing)
2216
2518
  */
2217
2519
  function findRouteByRouteInfo(node, routeInfo, parentPath) {
2218
- var _a;
2520
+ var _a, _b, _c;
2219
2521
  let matchedNode;
2220
2522
  let fallbackNode;
2221
2523
  // `<Route />` nodes are rendered inside of a <Routes /> node
2222
2524
  const routesChildren = (_a = getRoutesChildren(node)) !== null && _a !== void 0 ? _a : node;
2223
2525
  // Collect all route children
2224
- const routeChildren = React.Children.toArray(routesChildren).filter((child) => React.isValidElement(child) && child.type === Route);
2225
- // Sort routes by specificity (most specific first)
2226
- const sortedRoutes = routeChildren.sort((a, b) => {
2227
- const pathA = a.props.path || '';
2228
- const pathB = b.props.path || '';
2229
- // Index routes come first
2230
- if (a.props.index && !b.props.index)
2231
- return -1;
2232
- if (!a.props.index && b.props.index)
2233
- return 1;
2234
- // Wildcard-only routes (*) should come LAST
2235
- const aIsWildcardOnly = pathA === '*';
2236
- const bIsWildcardOnly = pathB === '*';
2237
- if (!aIsWildcardOnly && bIsWildcardOnly)
2238
- return -1;
2239
- if (aIsWildcardOnly && !bIsWildcardOnly)
2240
- return 1;
2241
- // Exact matches (no wildcards/params) come before wildcard/param routes
2242
- const aHasWildcard = pathA.includes('*') || pathA.includes(':');
2243
- const bHasWildcard = pathB.includes('*') || pathB.includes(':');
2244
- if (!aHasWildcard && bHasWildcard)
2245
- return -1;
2246
- if (aHasWildcard && !bHasWildcard)
2247
- return 1;
2248
- // Among routes with same wildcard status, longer paths are more specific
2249
- if (pathA.length !== pathB.length) {
2250
- return pathB.length - pathA.length;
2251
- }
2252
- return 0;
2253
- });
2254
- // For nested routes in React Router 6, we need to extract the relative path
2255
- // that this outlet should be responsible for matching
2256
- const originalPathname = routeInfo.pathname;
2257
- let relativePathnameToMatch = routeInfo.pathname;
2258
- // Check if we have relative routes (routes that don't start with '/')
2259
- const hasRelativeRoutes = sortedRoutes.some((r) => r.props.path && !r.props.path.startsWith('/'));
2260
- const hasIndexRoute = sortedRoutes.some((r) => r.props.index);
2261
- // SIMPLIFIED: Trust React Router 6's matching more, compute relative path when parent is known
2262
- if ((hasRelativeRoutes || hasIndexRoute) && parentPath) {
2263
- const parentPrefix = parentPath.replace('/*', '');
2264
- // Normalize both paths to start with '/' for consistent comparison
2265
- const normalizedParent = stripTrailingSlash(parentPrefix.startsWith('/') ? parentPrefix : `/${parentPrefix}`);
2266
- const normalizedPathname = stripTrailingSlash(routeInfo.pathname);
2267
- // Only compute relative path if pathname is within parent scope
2268
- if (normalizedPathname.startsWith(normalizedParent + '/') || normalizedPathname === normalizedParent) {
2269
- const pathSegments = routeInfo.pathname.split('/').filter(Boolean);
2270
- const parentSegments = normalizedParent.split('/').filter(Boolean);
2271
- const relativeSegments = pathSegments.slice(parentSegments.length);
2272
- relativePathnameToMatch = relativeSegments.join('/'); // Empty string is valid for index routes
2273
- }
2274
- }
2275
- // Find the first matching route
2276
- for (const child of sortedRoutes) {
2277
- const childPath = child.props.path;
2278
- const isAbsoluteRoute = childPath && childPath.startsWith('/');
2279
- // Determine which pathname to match against:
2280
- // - For absolute routes: use the original full pathname
2281
- // - For relative routes with a parent: use the computed relative pathname
2282
- // - For relative routes at root level (no parent): use the original pathname
2283
- // (matchPath will handle the relative-to-absolute normalization)
2284
- const pathnameToMatch = isAbsoluteRoute ? originalPathname : relativePathnameToMatch;
2285
- // Determine the path portion to match:
2286
- // - For absolute routes: use derivePathnameToMatch
2287
- // - For relative routes at root level (no parent): use original pathname
2288
- // directly since matchPath normalizes both path and pathname
2289
- // - For relative routes with parent: use derivePathnameToMatch for wildcards,
2290
- // or the computed relative pathname for non-wildcards
2291
- let pathForMatch;
2292
- if (isAbsoluteRoute) {
2293
- pathForMatch = derivePathnameToMatch(pathnameToMatch, childPath);
2294
- }
2295
- else if (!parentPath && childPath) {
2296
- // Root-level relative route: use the full pathname and let matchPath
2297
- // handle the normalization (it adds '/' to both path and pathname)
2298
- pathForMatch = originalPathname;
2299
- }
2300
- else if (childPath && childPath.includes('*')) {
2301
- // Relative wildcard route with parent path: use derivePathnameToMatch
2302
- pathForMatch = derivePathnameToMatch(pathnameToMatch, childPath);
2526
+ const routeChildren = React.Children.toArray(routesChildren).filter((child) => React.isValidElement(child) && (child.type === Route || child.type === IonRoute));
2527
+ // Delegate route matching to RR6's matchRoutes(), which handles specificity ranking internally.
2528
+ const basename = parentPath ? stripTrailingSlash(parentPath.replace('/*', '')) : undefined;
2529
+ const routeObjects = routeElementsToRouteObjects(routeChildren, basename);
2530
+ const matches = matchRoutes(routeObjects, { pathname: routeInfo.pathname }, basename);
2531
+ if (matches && matches.length > 0) {
2532
+ const bestMatch = matches[matches.length - 1];
2533
+ matchedNode = (_c = (_b = bestMatch.route.handle) === null || _b === void 0 ? void 0 : _b._element) !== null && _c !== void 0 ? _c : undefined;
2534
+ }
2535
+ // Fallback: try pathless routes, but only if pathname is within scope.
2536
+ if (!matchedNode) {
2537
+ let pathnameInScope = true;
2538
+ if (parentPath) {
2539
+ pathnameInScope = isPathnameInScope(routeInfo.pathname, parentPath);
2303
2540
  }
2304
2541
  else {
2305
- pathForMatch = pathnameToMatch;
2306
- }
2307
- const match = matchPath({
2308
- pathname: pathForMatch,
2309
- componentProps: child.props,
2310
- });
2311
- if (match) {
2312
- matchedNode = child;
2313
- break;
2314
- }
2315
- }
2316
- if (matchedNode) {
2317
- return matchedNode;
2318
- }
2319
- // If we haven't found a node, try to find one that doesn't have a path prop (fallback route)
2320
- // BUT only return the fallback if the current pathname is within the outlet's scope.
2321
- // For outlets with absolute paths, compute the common prefix to determine scope.
2322
- const absolutePathRoutes = routeChildren.filter((r) => r.props.path && r.props.path.startsWith('/'));
2323
- // Determine if pathname is within scope before returning fallback
2324
- let isPathnameInScope = true;
2325
- if (absolutePathRoutes.length > 0) {
2326
- // Find common prefix of all absolute paths to determine outlet scope
2327
- const absolutePaths = absolutePathRoutes.map((r) => r.props.path);
2328
- const commonPrefix = computeCommonPrefix(absolutePaths);
2329
- // If we have a common prefix, check if the current pathname is within that scope
2330
- if (commonPrefix && commonPrefix !== '/') {
2331
- isPathnameInScope = routeInfo.pathname.startsWith(commonPrefix);
2542
+ const absolutePathRoutes = routeChildren.filter((r) => r.props.path && r.props.path.startsWith('/'));
2543
+ if (absolutePathRoutes.length > 0) {
2544
+ const absolutePaths = absolutePathRoutes.map((r) => r.props.path);
2545
+ const commonPrefix = computeCommonPrefix(absolutePaths);
2546
+ if (commonPrefix && commonPrefix !== '/') {
2547
+ pathnameInScope = routeInfo.pathname.startsWith(commonPrefix);
2548
+ }
2549
+ }
2332
2550
  }
2333
- }
2334
- // Only look for fallback route if pathname is within scope
2335
- if (isPathnameInScope) {
2336
- for (const child of routeChildren) {
2337
- if (!child.props.path) {
2338
- fallbackNode = child;
2339
- break;
2551
+ if (pathnameInScope) {
2552
+ for (const child of routeChildren) {
2553
+ if (!child.props.path) {
2554
+ fallbackNode = child;
2555
+ break;
2556
+ }
2340
2557
  }
2341
2558
  }
2342
2559
  }
2343
2560
  return matchedNode !== null && matchedNode !== void 0 ? matchedNode : fallbackNode;
2344
2561
  }
2345
- function matchComponent(node, pathname, forceExact) {
2562
+ function matchComponent(node, pathname, forceExact, parentPath) {
2346
2563
  var _a;
2347
2564
  const routePath = (_a = node === null || node === void 0 ? void 0 : node.props) === null || _a === void 0 ? void 0 : _a.path;
2348
- const pathnameToMatch = derivePathnameToMatch(pathname, routePath);
2565
+ let pathnameToMatch;
2566
+ if (parentPath && routePath && !routePath.startsWith('/')) {
2567
+ // When parent path is known, compute exact relative pathname
2568
+ const relative = pathname.startsWith(parentPath)
2569
+ ? pathname.slice(parentPath.length).replace(/^\//, '')
2570
+ : pathname;
2571
+ pathnameToMatch = relative;
2572
+ }
2573
+ else {
2574
+ pathnameToMatch = derivePathnameToMatch(pathname, routePath);
2575
+ }
2349
2576
  return matchPath({
2350
2577
  pathname: pathnameToMatch,
2351
2578
  componentProps: Object.assign(Object.assign({}, node.props), { end: forceExact }),
@@ -2426,6 +2653,16 @@ const IonRouter = ({ children, registerHistoryListener }) => {
2426
2653
  // for future navigations once React has committed the mount. This avoids
2427
2654
  // duplicate entries when React StrictMode runs an extra render pre-commit.
2428
2655
  locationHistory.current.add(routeInfo);
2656
+ // If IonTabBar already called handleSetCurrentTab during render (before this
2657
+ // effect), the tab was stored in currentTab.current but the history entry was
2658
+ // not yet seeded. Apply the pending tab to the seed entry now.
2659
+ if (currentTab.current) {
2660
+ const ri = Object.assign({}, locationHistory.current.current());
2661
+ if (ri.tab !== currentTab.current) {
2662
+ ri.tab = currentTab.current;
2663
+ locationHistory.current.update(ri);
2664
+ }
2665
+ }
2429
2666
  registerHistoryListener(handleHistoryChange);
2430
2667
  didMountRef.current = true;
2431
2668
  }, []);
@@ -2484,15 +2721,19 @@ const IonRouter = ({ children, registerHistoryListener }) => {
2484
2721
  leavingLocationInfo = locationHistory.current.current();
2485
2722
  }
2486
2723
  const leavingUrl = leavingLocationInfo.pathname + leavingLocationInfo.search;
2487
- if (leavingUrl !== location.pathname) {
2724
+ if (leavingUrl !== location.pathname + location.search) {
2488
2725
  if (!incomingRouteParams.current) {
2489
- // Determine if the destination is a tab route by checking if it matches
2490
- // the pattern of tab routes (containing /tabs/ in the path)
2491
- const isTabRoute = /\/tabs(\/|$)/.test(location.pathname);
2492
- const tabToUse = isTabRoute ? currentTab.current : undefined;
2493
- // If we're leaving tabs entirely, clear the current tab
2494
- if (!isTabRoute && currentTab.current) {
2495
- currentTab.current = undefined;
2726
+ // Use history-based tab detection instead of URL-pattern heuristics,
2727
+ // so tab routes work with any URL structure (not just paths containing "/tabs").
2728
+ // Fall back to currentTab.current only when the destination is within the
2729
+ // current tab's path hierarchy (prevents non-tab routes from inheriting a tab).
2730
+ let tabToUse = locationHistory.current.findTabForPathname(location.pathname);
2731
+ if (!tabToUse && currentTab.current) {
2732
+ const tabFirstRoute = locationHistory.current.getFirstRouteInfoForTab(currentTab.current);
2733
+ const tabRootPath = tabFirstRoute === null || tabFirstRoute === void 0 ? void 0 : tabFirstRoute.pathname;
2734
+ if (tabRootPath && (location.pathname === tabRootPath || location.pathname.startsWith(tabRootPath + '/'))) {
2735
+ tabToUse = currentTab.current;
2736
+ }
2496
2737
  }
2497
2738
  /**
2498
2739
  * A `REPLACE` action can be triggered by React Router's
@@ -2532,6 +2773,8 @@ const IonRouter = ({ children, registerHistoryListener }) => {
2532
2773
  }
2533
2774
  else {
2534
2775
  // It's a non-linear history path like a direct link.
2776
+ // Still push the current location key so browser forward is detectable.
2777
+ forwardStack.current.push(currentLocationKeyRef.current);
2535
2778
  incomingRouteParams.current = {
2536
2779
  routeAction: 'pop',
2537
2780
  routeDirection: 'none',
@@ -2556,7 +2799,7 @@ const IonRouter = ({ children, registerHistoryListener }) => {
2556
2799
  }
2557
2800
  let routeInfo;
2558
2801
  // If we're navigating away from tabs to a non-tab route, clear the current tab
2559
- if (!/\/tabs(\/|$)/.test(location.pathname) && currentTab.current) {
2802
+ if (!locationHistory.current.findTabForPathname(location.pathname) && currentTab.current) {
2560
2803
  currentTab.current = undefined;
2561
2804
  }
2562
2805
  /**
@@ -2711,7 +2954,13 @@ const IonRouter = ({ children, registerHistoryListener }) => {
2711
2954
  */
2712
2955
  const handleSetCurrentTab = (tab) => {
2713
2956
  currentTab.current = tab;
2714
- const ri = Object.assign({}, locationHistory.current.current());
2957
+ const current = locationHistory.current.current();
2958
+ if (!current) {
2959
+ // locationHistory not yet seeded (e.g., called during initial render
2960
+ // before mount effect). The mount effect will seed the correct entry.
2961
+ return;
2962
+ }
2963
+ const ri = Object.assign({}, current);
2715
2964
  if (ri.tab !== tab) {
2716
2965
  ri.tab = tab;
2717
2966
  locationHistory.current.update(ri);
@@ -2755,7 +3004,7 @@ const IonRouter = ({ children, registerHistoryListener }) => {
2755
3004
  * e.g., `/home` → `/settings` → back to `/home`
2756
3005
  */
2757
3006
  const condition1 = routeInfo.lastPathname === routeInfo.pushedByRoute;
2758
- const condition2 = prevInfo.pathname === routeInfo.pushedByRoute && routeInfo.tab === '' && prevInfo.tab === '';
3007
+ const condition2 = prevInfo.pathname === routeInfo.pushedByRoute && !routeInfo.tab && !prevInfo.tab;
2759
3008
  if (condition1 || condition2) {
2760
3009
  // Record the current location key so browser forward is detectable
2761
3010
  forwardStack.current.push(currentLocationKeyRef.current);
@@ -2809,26 +3058,29 @@ const IonRouter = ({ children, registerHistoryListener }) => {
2809
3058
  // is also within tabs. If not, we should clear the tab context.
2810
3059
  let navigationTab = tab;
2811
3060
  // If no explicit tab is provided and we're in a tab context,
2812
- // check if the destination path is outside of the current tab context
3061
+ // check if the destination path is outside of the current tab context.
3062
+ // Uses history-based tab detection instead of URL pattern matching,
3063
+ // so it works with any tab URL structure.
2813
3064
  if (!tab && currentTab.current && path) {
2814
- // Get the current route info to understand where we are
2815
- const currentRoute = locationHistory.current.current();
2816
- // If we're navigating from a tab route to a completely different path structure,
2817
- // we should clear the tab context. This is a simplified check that assumes
2818
- // tab routes share a common parent path.
2819
- if (currentRoute && currentRoute.pathname) {
2820
- // Extract the base tab path (e.g., /routing/tabs from /routing/tabs/home)
2821
- const tabBaseMatch = currentRoute.pathname.match(/^(.*\/tabs)/);
2822
- if (tabBaseMatch) {
2823
- const tabBasePath = tabBaseMatch[1];
2824
- // If the new path doesn't start with the tab base path, we're leaving tabs
2825
- if (!path.startsWith(tabBasePath)) {
2826
- currentTab.current = undefined;
2827
- navigationTab = undefined;
3065
+ // Check if destination was previously visited in a tab context
3066
+ const destinationTab = locationHistory.current.findTabForPathname(path);
3067
+ if (destinationTab) {
3068
+ // Previously visited as a tab route - use the known tab
3069
+ navigationTab = destinationTab;
3070
+ }
3071
+ else {
3072
+ // New destination - check if it's a child of the current tab's root path
3073
+ const tabFirstRoute = locationHistory.current.getFirstRouteInfoForTab(currentTab.current);
3074
+ if (tabFirstRoute) {
3075
+ const tabRootPath = tabFirstRoute.pathname;
3076
+ if (path === tabRootPath || path.startsWith(tabRootPath + '/')) {
3077
+ // Still within the current tab's path hierarchy
3078
+ navigationTab = currentTab.current;
2828
3079
  }
2829
3080
  else {
2830
- // Still within tabs, preserve the tab context
2831
- navigationTab = currentTab.current;
3081
+ // Destination is outside the current tab context
3082
+ currentTab.current = undefined;
3083
+ navigationTab = undefined;
2832
3084
  }
2833
3085
  }
2834
3086
  }
@@ -2914,9 +3166,9 @@ const RouterContent$1 = ({ children }) => {
2914
3166
  const location = useLocation$1();
2915
3167
  const navigationType = useNavigationType$1();
2916
3168
  const historyListenHandler = useRef();
2917
- const registerHistoryListener = (cb) => {
3169
+ const registerHistoryListener = useCallback((cb) => {
2918
3170
  historyListenHandler.current = cb;
2919
- };
3171
+ }, []);
2920
3172
  /**
2921
3173
  * Processes navigation changes within the application.
2922
3174
  *
@@ -2929,14 +3181,14 @@ const RouterContent$1 = ({ children }) => {
2929
3181
  * @param action The type of navigation action ('PUSH', 'POP', or
2930
3182
  * 'REPLACE').
2931
3183
  */
2932
- const handleHistoryChange = (location, action) => {
3184
+ const handleHistoryChange = useCallback((loc, act) => {
2933
3185
  if (historyListenHandler.current) {
2934
- historyListenHandler.current(location, action);
3186
+ historyListenHandler.current(loc, act);
2935
3187
  }
2936
- };
3188
+ }, []);
2937
3189
  useEffect(() => {
2938
3190
  handleHistoryChange(location, navigationType);
2939
- }, [location, navigationType]);
3191
+ }, [location, navigationType, handleHistoryChange]);
2940
3192
  return React.createElement(IonRouter, { registerHistoryListener: registerHistoryListener }, children);
2941
3193
  };
2942
3194
  const IonReactMemoryRouter = (_a) => {
@@ -2953,9 +3205,9 @@ const RouterContent = ({ children }) => {
2953
3205
  const location = useLocation();
2954
3206
  const navigationType = useNavigationType();
2955
3207
  const historyListenHandler = useRef();
2956
- const registerHistoryListener = (cb) => {
3208
+ const registerHistoryListener = useCallback((cb) => {
2957
3209
  historyListenHandler.current = cb;
2958
- };
3210
+ }, []);
2959
3211
  /**
2960
3212
  * Processes navigation changes within the application.
2961
3213
  *
@@ -2968,14 +3220,14 @@ const RouterContent = ({ children }) => {
2968
3220
  * @param action The type of navigation action ('PUSH', 'POP', or
2969
3221
  * 'REPLACE').
2970
3222
  */
2971
- const handleHistoryChange = (location, action) => {
3223
+ const handleHistoryChange = useCallback((loc, act) => {
2972
3224
  if (historyListenHandler.current) {
2973
- historyListenHandler.current(location, action);
3225
+ historyListenHandler.current(loc, act);
2974
3226
  }
2975
- };
3227
+ }, []);
2976
3228
  useEffect(() => {
2977
3229
  handleHistoryChange(location, navigationType);
2978
- }, [location, navigationType]);
3230
+ }, [location, navigationType, handleHistoryChange]);
2979
3231
  return React.createElement(IonRouter, { registerHistoryListener: registerHistoryListener }, children);
2980
3232
  };
2981
3233
  const IonReactHashRouter = (_a) => {