@ionic/react-router 9.0.5 → 9.0.6-dev.11790280751.1fc334cc

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
@@ -499,6 +499,41 @@ const compareRouteSpecificity = (a, b) => {
499
499
  }
500
500
  return 0;
501
501
  };
502
+ /**
503
+ * True when a route matches more pathnames than its own path, so a splat, an index route,
504
+ * or a route with an empty or absent path. A lookup can return one of these for a pathname
505
+ * a more specific sibling owns, so callers must confirm ownership against React Router's
506
+ * ranking before reusing the view item.
507
+ *
508
+ * This is deliberately wider than the catch-all checks in ReactRouterViewStack, which each
509
+ * gate on a narrower shape for a different reason. Don't unify them with this helper.
510
+ */
511
+ const isOverMatchingRoute = (route) => {
512
+ const { path, index } = route;
513
+ return !path || path.includes('*') || !!index;
514
+ };
515
+ /** True when the navigation pushed a new page forward on top of the current one. */
516
+ const isForwardPush = (routeInfo) => routeInfo.routeAction === 'push' && routeInfo.routeDirection === 'forward';
517
+ const swipeRevealed = new WeakSet();
518
+ /**
519
+ * Marks the page a swipe-back gesture has revealed. For the length of the drag that page
520
+ * is on screen while a more specific sibling still matches the current pathname, and the
521
+ * deactivation scan in `renderViewItem` would otherwise re-hide it on the next render and
522
+ * leave the user dragging a blank page.
523
+ */
524
+ const markSwipeRevealed = (viewItem) => {
525
+ if (viewItem) {
526
+ swipeRevealed.add(viewItem);
527
+ }
528
+ };
529
+ /** Drops the mark once the gesture ends, so the view is hidden normally again. */
530
+ const clearSwipeRevealed = (viewItem) => {
531
+ if (viewItem) {
532
+ swipeRevealed.delete(viewItem);
533
+ }
534
+ };
535
+ /** True while a swipe-back gesture is showing this view. */
536
+ const isSwipeRevealed = (viewItem) => swipeRevealed.has(viewItem);
502
537
  /**
503
538
  * Sorts view items by route specificity (most specific first).
504
539
  *
@@ -860,7 +895,7 @@ class ReactRouterViewStack extends ViewStacks {
860
895
  }
861
896
  // Deactivate wildcard (catch-all) and empty-path (default) routes when a more-specific route matches.
862
897
  // This prevents "Not found" or fallback pages from showing alongside valid routes.
863
- if (routePath === '*' || routePath === '') {
898
+ if (routePath === '*' || routePath === '/*' || routePath === '') {
864
899
  // Check if any other view in this outlet has a match for the current route
865
900
  const outletViews = this.getViewItemsForOutlet(viewItem.outletId);
866
901
  // When parent path context is available, compute the relative pathname once
@@ -913,8 +948,15 @@ class ReactRouterViewStack extends ViewStacks {
913
948
  }
914
949
  }
915
950
  }
916
- if (hasSpecificMatch) {
917
- viewItem.mount = false;
951
+ if (hasSpecificMatch && !isSwipeRevealed(viewItem)) {
952
+ // A splat can be the outlet's container page rather than a "not found" fallback.
953
+ // Pushed over it is the page underneath, so unmounting it destroys its state and
954
+ // leaves nothing for back to reveal. Hiding it below covers that. A view with no
955
+ // ion-page was never a page in the stack and has nothing to hide, so it still
956
+ // unmounts.
957
+ if (!isForwardPush(routeInfo) || !viewItem.ionPageElement) {
958
+ viewItem.mount = false;
959
+ }
918
960
  if (viewItem.ionPageElement) {
919
961
  viewItem.ionPageElement.classList.add('ion-page-hidden');
920
962
  viewItem.ionPageElement.setAttribute('aria-hidden', 'true');
@@ -984,8 +1026,11 @@ class ReactRouterViewStack extends ViewStacks {
984
1026
  parentPath = result.parentPath;
985
1027
  // Persist the mount path for subsequent calls, mirroring StackManager.outletMountPath.
986
1028
  // Unlike outletParentPaths (cleared when parentPath is undefined), the mount path is
987
- // intentionally sticky — it anchors the outlet's scope and is only removed in clear().
988
- if (result.outletMountPath && !this.outletMountPaths.has(outletId)) {
1029
+ // intentionally sticky, it anchors the outlet's scope and is only removed in clear().
1030
+ // A root outlet is skipped because it is mounted under nothing, so an inferred path
1031
+ // would scope it to whatever route was active. parentPathnameBase is undefined
1032
+ // exactly when the outlet has no parent matches.
1033
+ if (parentPathnameBase && result.outletMountPath && !this.outletMountPaths.has(outletId)) {
989
1034
  this.outletMountPaths.set(outletId, result.outletMountPath);
990
1035
  }
991
1036
  }
@@ -1435,6 +1480,8 @@ class StackManager extends React.PureComponent {
1435
1480
  routerOutletElement;
1436
1481
  prevProps;
1437
1482
  skipTransition;
1483
+ /** The view item the in-flight swipe gesture revealed, so the same one gets cleared. */
1484
+ swipeRevealedViewItem;
1438
1485
  stackContextValue = {
1439
1486
  registerIonPage: this.registerIonPage.bind(this),
1440
1487
  isInOutlet: () => true,
@@ -1526,7 +1573,9 @@ class StackManager extends React.PureComponent {
1526
1573
  hasIndexRoute,
1527
1574
  hasWildcardRoute,
1528
1575
  });
1529
- if (result.outletMountPath && !this.outletMountPath) {
1576
+ // A root outlet is mounted under nothing, so caching the inferred path would scope
1577
+ // it to whatever route was active and make every other pathname look out of scope.
1578
+ if (!this.isRootOutlet && result.outletMountPath && !this.outletMountPath) {
1530
1579
  this.outletMountPath = result.outletMountPath;
1531
1580
  }
1532
1581
  return result.parentPath;
@@ -1581,12 +1630,16 @@ class StackManager extends React.PureComponent {
1581
1630
  return true;
1582
1631
  }
1583
1632
  // For non-replace actions, only unmount for back navigation
1584
- const isForwardPush = routeInfo.routeAction === 'push' && routeInfo.routeDirection === 'forward';
1585
- if (!isForwardPush && routeInfo.routeDirection !== 'none' && enteringViewItem !== leavingViewItem) {
1633
+ if (!isForwardPush(routeInfo) && routeInfo.routeDirection !== 'none' && enteringViewItem !== leavingViewItem) {
1586
1634
  return true;
1587
1635
  }
1588
1636
  return false;
1589
1637
  }
1638
+ /** Clears the reveal flag from the view item the gesture marked. */
1639
+ clearSwipeRevealedView() {
1640
+ clearSwipeRevealed(this.swipeRevealedViewItem);
1641
+ this.swipeRevealedViewItem = undefined;
1642
+ }
1590
1643
  /**
1591
1644
  * Handles out-of-scope outlet. Returns true if transition should be aborted.
1592
1645
  */
@@ -1756,7 +1809,9 @@ class StackManager extends React.PureComponent {
1756
1809
  const containerBase = routePath.replace(/\/\*$/, '');
1757
1810
  const currentInContainer = routeInfo.pathname.startsWith(containerBase + '/') || routeInfo.pathname === containerBase;
1758
1811
  const previousInContainer = routeInfo.lastPathname.startsWith(containerBase + '/') || routeInfo.lastPathname === containerBase;
1759
- if (currentInContainer && previousInContainer) {
1812
+ // A root-level "/*" leaves an empty base, so both checks above are true for every
1813
+ // pathname and the shortcut would otherwise skip every navigation in this outlet.
1814
+ if (containerBase !== '' && currentInContainer && previousInContainer) {
1760
1815
  const updatedMatch = matchComponent(enteringViewItem.reactElement, routeInfo.pathname, true, this.outletMountPath);
1761
1816
  if (updatedMatch) {
1762
1817
  enteringViewItem.routeData.match = updatedMatch;
@@ -2218,6 +2273,7 @@ class StackManager extends React.PureComponent {
2218
2273
  }
2219
2274
  this.waitingForIonPage = false;
2220
2275
  this.preservedViewItems.clear();
2276
+ this.clearSwipeRevealedView();
2221
2277
  // Hide all views in this outlet before clearing.
2222
2278
  // This is critical for nested outlets - when the parent component unmounts,
2223
2279
  // the nested outlet's componentDidUpdate won't be called, so we must hide
@@ -2249,13 +2305,32 @@ class StackManager extends React.PureComponent {
2249
2305
  this.pendingPageTransition = true;
2250
2306
  return;
2251
2307
  }
2308
+ // A completed swipe keeps its reveal flag past goBack() so the deactivation scan does
2309
+ // not re-hide the page between the gesture ending and the URL settling. Clear it at
2310
+ // the start of the transition for the new pathname.
2311
+ this.clearSwipeRevealedView();
2252
2312
  // Find entering and leaving view items
2253
2313
  const viewItems = this.findViewItems(routeInfo);
2254
2314
  let enteringViewItem = viewItems.enteringViewItem;
2255
2315
  let leavingViewItem = viewItems.leavingViewItem;
2256
- let shouldUnmountLeavingViewItem = this.shouldUnmountLeavingView(routeInfo, enteringViewItem, leavingViewItem);
2257
2316
  // Get parent path for nested outlets
2258
2317
  const parentPath = this.getParentPath();
2318
+ // Find the matching route element. This is React Router's own ranking of the outlet's
2319
+ // routes, so it decides which route owns the pathname.
2320
+ const enteringRoute = findRouteByRouteInfo(this.ionRouterOutlet?.props.children, routeInfo, parentPath);
2321
+ // The lookup above only matches view items that already exist, so a sibling route with
2322
+ // no view item yet never wins there and an over-matching route (splat, index, empty
2323
+ // path) comes back for pathnames it does not own. Drop it rather than reusing it,
2324
+ // because overwriting its reactElement below would swap its page for the winning
2325
+ // route's and unmount it. This has to resolve before shouldUnmountLeavingView and
2326
+ // handleRootNavigation, which both branch on the entering view item.
2327
+ if (enteringViewItem &&
2328
+ enteringRoute &&
2329
+ isOverMatchingRoute(enteringViewItem.routeData?.childProps ?? {}) &&
2330
+ enteringRoute.props.path !== enteringViewItem.routeData?.childProps?.path) {
2331
+ enteringViewItem = undefined;
2332
+ }
2333
+ let shouldUnmountLeavingViewItem = this.shouldUnmountLeavingView(routeInfo, enteringViewItem, leavingViewItem);
2259
2334
  // Handle out-of-scope outlet (route outside mount path)
2260
2335
  if (this.handleOutOfScopeOutlet(routeInfo)) {
2261
2336
  return;
@@ -2275,8 +2350,6 @@ class StackManager extends React.PureComponent {
2275
2350
  if (this.handleOutOfContextNestedOutlet(parentPath, leavingViewItem)) {
2276
2351
  return;
2277
2352
  }
2278
- // Find the matching route element
2279
- const enteringRoute = findRouteByRouteInfo(this.ionRouterOutlet?.props.children, routeInfo, parentPath);
2280
2353
  // Handle nested outlet with no matching route
2281
2354
  if (this.handleNoMatchingRoute(enteringRoute, enteringViewItem, leavingViewItem)) {
2282
2355
  return;
@@ -2497,6 +2570,9 @@ class StackManager extends React.PureComponent {
2497
2570
  return canStartSwipe;
2498
2571
  };
2499
2572
  const onStart = async () => {
2573
+ // Core only calls onEnd when a progress animation exists, so with animations off a
2574
+ // previous gesture can leave its mark behind. Drop any stale one before marking.
2575
+ this.clearSwipeRevealedView();
2500
2576
  const { routeInfo } = this.props;
2501
2577
  const swipeBackRouteInfo = this.getSwipeBackRouteInfo();
2502
2578
  const enteringViewItem = this.findEnteringViewForSwipe(swipeBackRouteInfo);
@@ -2523,6 +2599,11 @@ class StackManager extends React.PureComponent {
2523
2599
  revealIonPageForSwipeBack(enteringViewItem?.ionPageElement);
2524
2600
  // When the gesture starts, kick off a transition controlled via swipe gesture
2525
2601
  if (enteringViewItem && leavingViewItem) {
2602
+ // Without a leaving view there is no progress animation, and core only calls
2603
+ // swipeHandler.onEnd when one exists, so nothing would clear the mark until the
2604
+ // next transition.
2605
+ markSwipeRevealed(enteringViewItem);
2606
+ this.swipeRevealedViewItem = enteringViewItem;
2526
2607
  await this.transitionPage(routeInfo, enteringViewItem, leavingViewItem, 'back', true);
2527
2608
  }
2528
2609
  debug('SwipeBackOnStart:exit', () => ({
@@ -2547,6 +2628,7 @@ class StackManager extends React.PureComponent {
2547
2628
  const swipeBackRouteInfo = this.getSwipeBackRouteInfo();
2548
2629
  const enteringViewItem = this.findEnteringViewForSwipe(swipeBackRouteInfo);
2549
2630
  const leavingViewItem = this.context.findViewItemByRouteInfo(routeInfo, this.id, false);
2631
+ this.clearSwipeRevealedView();
2550
2632
  // Don't hide if entering and leaving are the same (parameterized route edge case)
2551
2633
  if (enteringViewItem !== leavingViewItem && enteringViewItem?.ionPageElement !== undefined) {
2552
2634
  hideIonPageElement(enteringViewItem.ionPageElement);