@ionic/react-router 9.0.6-dev.11790615145.1d8c56a2 → 9.0.6-dev.11790776272.1d27d1bd

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
  }
@@ -1407,12 +1452,6 @@ const revealIonPageForSwipeBack = (element) => {
1407
1452
  element.removeAttribute('aria-hidden');
1408
1453
  }
1409
1454
  };
1410
- /** Dispatches a view lifecycle event the way core's `lifecycle()` does. */
1411
- const dispatchLifecycleEvent = (element, eventName) => {
1412
- if (element) {
1413
- element.dispatchEvent(new CustomEvent(eventName, { bubbles: false, cancelable: false }));
1414
- }
1415
- };
1416
1455
  /**
1417
1456
  * A leaf view is "preservable" on browser-back (pop) when its React state
1418
1457
  * should survive a forward-pop round-trip. Non-parameterized leaf paths
@@ -1441,6 +1480,8 @@ class StackManager extends React.PureComponent {
1441
1480
  routerOutletElement;
1442
1481
  prevProps;
1443
1482
  skipTransition;
1483
+ /** The view item the in-flight swipe gesture revealed, so the same one gets cleared. */
1484
+ swipeRevealedViewItem;
1444
1485
  stackContextValue = {
1445
1486
  registerIonPage: this.registerIonPage.bind(this),
1446
1487
  isInOutlet: () => true,
@@ -1532,7 +1573,9 @@ class StackManager extends React.PureComponent {
1532
1573
  hasIndexRoute,
1533
1574
  hasWildcardRoute,
1534
1575
  });
1535
- 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) {
1536
1579
  this.outletMountPath = result.outletMountPath;
1537
1580
  }
1538
1581
  return result.parentPath;
@@ -1587,12 +1630,16 @@ class StackManager extends React.PureComponent {
1587
1630
  return true;
1588
1631
  }
1589
1632
  // For non-replace actions, only unmount for back navigation
1590
- const isForwardPush = routeInfo.routeAction === 'push' && routeInfo.routeDirection === 'forward';
1591
- if (!isForwardPush && routeInfo.routeDirection !== 'none' && enteringViewItem !== leavingViewItem) {
1633
+ if (!isForwardPush(routeInfo) && routeInfo.routeDirection !== 'none' && enteringViewItem !== leavingViewItem) {
1592
1634
  return true;
1593
1635
  }
1594
1636
  return false;
1595
1637
  }
1638
+ /** Clears the reveal flag from the view item the gesture marked. */
1639
+ clearSwipeRevealedView() {
1640
+ clearSwipeRevealed(this.swipeRevealedViewItem);
1641
+ this.swipeRevealedViewItem = undefined;
1642
+ }
1596
1643
  /**
1597
1644
  * Handles out-of-scope outlet. Returns true if transition should be aborted.
1598
1645
  */
@@ -1641,8 +1688,8 @@ class StackManager extends React.PureComponent {
1641
1688
  const allViewsInOutlet = this.context.getViewItemsForOutlet(this.id);
1642
1689
  allViewsInOutlet.forEach((viewItem) => {
1643
1690
  if (viewItem.ionPageElement && isViewVisible(viewItem.ionPageElement)) {
1644
- dispatchLifecycleEvent(viewItem.ionPageElement, 'ionViewWillLeave');
1645
- dispatchLifecycleEvent(viewItem.ionPageElement, 'ionViewDidLeave');
1691
+ viewItem.ionPageElement.dispatchEvent(new CustomEvent('ionViewWillLeave', { bubbles: false, cancelable: false }));
1692
+ viewItem.ionPageElement.dispatchEvent(new CustomEvent('ionViewDidLeave', { bubbles: false, cancelable: false }));
1646
1693
  }
1647
1694
  });
1648
1695
  // Defer removal of view items to allow the parent outlet's leaving-page
@@ -1677,8 +1724,8 @@ class StackManager extends React.PureComponent {
1677
1724
  return;
1678
1725
  }
1679
1726
  if (viewItem.ionPageElement && isViewVisible(viewItem.ionPageElement)) {
1680
- dispatchLifecycleEvent(viewItem.ionPageElement, 'ionViewWillLeave');
1681
- dispatchLifecycleEvent(viewItem.ionPageElement, 'ionViewDidLeave');
1727
+ viewItem.ionPageElement.dispatchEvent(new CustomEvent('ionViewWillLeave', { bubbles: false, cancelable: false }));
1728
+ viewItem.ionPageElement.dispatchEvent(new CustomEvent('ionViewDidLeave', { bubbles: false, cancelable: false }));
1682
1729
  }
1683
1730
  this.context.unMountViewItem(viewItem);
1684
1731
  });
@@ -1762,7 +1809,9 @@ class StackManager extends React.PureComponent {
1762
1809
  const containerBase = routePath.replace(/\/\*$/, '');
1763
1810
  const currentInContainer = routeInfo.pathname.startsWith(containerBase + '/') || routeInfo.pathname === containerBase;
1764
1811
  const previousInContainer = routeInfo.lastPathname.startsWith(containerBase + '/') || routeInfo.lastPathname === containerBase;
1765
- 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) {
1766
1815
  const updatedMatch = matchComponent(enteringViewItem.reactElement, routeInfo.pathname, true, this.outletMountPath);
1767
1816
  if (updatedMatch) {
1768
1817
  enteringViewItem.routeData.match = updatedMatch;
@@ -2224,6 +2273,7 @@ class StackManager extends React.PureComponent {
2224
2273
  }
2225
2274
  this.waitingForIonPage = false;
2226
2275
  this.preservedViewItems.clear();
2276
+ this.clearSwipeRevealedView();
2227
2277
  // Hide all views in this outlet before clearing.
2228
2278
  // This is critical for nested outlets - when the parent component unmounts,
2229
2279
  // the nested outlet's componentDidUpdate won't be called, so we must hide
@@ -2255,13 +2305,32 @@ class StackManager extends React.PureComponent {
2255
2305
  this.pendingPageTransition = true;
2256
2306
  return;
2257
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();
2258
2312
  // Find entering and leaving view items
2259
2313
  const viewItems = this.findViewItems(routeInfo);
2260
2314
  let enteringViewItem = viewItems.enteringViewItem;
2261
2315
  let leavingViewItem = viewItems.leavingViewItem;
2262
- let shouldUnmountLeavingViewItem = this.shouldUnmountLeavingView(routeInfo, enteringViewItem, leavingViewItem);
2263
2316
  // Get parent path for nested outlets
2264
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
+ const matchedRouteProps = enteringViewItem?.routeData?.childProps;
2328
+ const matchedAnOverMatchingRoute = isOverMatchingRoute(matchedRouteProps ?? {});
2329
+ const routeOwnerDisagrees = enteringRoute !== undefined && enteringRoute.props.path !== matchedRouteProps?.path;
2330
+ if (enteringViewItem && matchedAnOverMatchingRoute && routeOwnerDisagrees) {
2331
+ enteringViewItem = undefined;
2332
+ }
2333
+ let shouldUnmountLeavingViewItem = this.shouldUnmountLeavingView(routeInfo, enteringViewItem, leavingViewItem);
2265
2334
  // Handle out-of-scope outlet (route outside mount path)
2266
2335
  if (this.handleOutOfScopeOutlet(routeInfo)) {
2267
2336
  return;
@@ -2281,8 +2350,6 @@ class StackManager extends React.PureComponent {
2281
2350
  if (this.handleOutOfContextNestedOutlet(parentPath, leavingViewItem)) {
2282
2351
  return;
2283
2352
  }
2284
- // Find the matching route element
2285
- const enteringRoute = findRouteByRouteInfo(this.ionRouterOutlet?.props.children, routeInfo, parentPath);
2286
2353
  // Handle nested outlet with no matching route
2287
2354
  if (this.handleNoMatchingRoute(enteringRoute, enteringViewItem, leavingViewItem)) {
2288
2355
  return;
@@ -2479,17 +2546,17 @@ class StackManager extends React.PureComponent {
2479
2546
  const { routeInfo } = this.props;
2480
2547
  const swipeBackRouteInfo = this.getSwipeBackRouteInfo();
2481
2548
  const enteringViewItem = this.findEnteringViewForSwipe(swipeBackRouteInfo);
2549
+ const leavingViewItem = this.context.findViewItemByRouteInfo(routeInfo, this.id, false);
2482
2550
  // View might have mount=false but ionPageElement still in DOM
2483
2551
  const ionPageInDocument = Boolean(enteringViewItem?.ionPageElement && document.body.contains(enteringViewItem.ionPageElement));
2484
- // For wildcard/parameterized routes, the pattern path (e.g. "/foo/*") will
2485
- // never equal the resolved pathname (e.g. "/foo/bar"), so the pattern check
2486
- // alone isn't sufficient. Also, verify the entering view's resolved pathname
2487
- // differs from the current pathname — if they match, the entering and leaving
2488
- // views are the same and the swipe gesture shouldn't start.
2552
+ // A splat consumes whatever is left of the pathname, so its match resolves to the whole
2553
+ // current pathname and comparing pathnames can't tell a container page underneath a
2554
+ // pushed sibling from the page being left. Compare the view items instead, like onEnd
2555
+ // does. Without a leaving view onStart skips the transition, so reject that here too.
2489
2556
  const canStartSwipe = !!enteringViewItem &&
2557
+ !!leavingViewItem &&
2490
2558
  (enteringViewItem.mount || ionPageInDocument) &&
2491
- enteringViewItem.routeData.match.pattern.path !== routeInfo.pathname &&
2492
- enteringViewItem.routeData.match.pathname !== routeInfo.pathname;
2559
+ enteringViewItem !== leavingViewItem;
2493
2560
  debug('SwipeBackCanStart', () => ({
2494
2561
  outletId: this.id,
2495
2562
  routePathname: routeInfo.pathname,
@@ -2498,11 +2565,15 @@ class StackManager extends React.PureComponent {
2498
2565
  enteringViewPath: enteringViewItem?.reactElement?.props?.path,
2499
2566
  enteringMount: enteringViewItem?.mount,
2500
2567
  ionPageInDocument,
2568
+ leavingViewId: leavingViewItem?.id,
2501
2569
  canStartSwipe,
2502
2570
  }));
2503
2571
  return canStartSwipe;
2504
2572
  };
2505
2573
  const onStart = async () => {
2574
+ // Core only calls onEnd when a progress animation exists, so with animations off a
2575
+ // previous gesture can leave its mark behind. Drop any stale one before marking.
2576
+ this.clearSwipeRevealedView();
2506
2577
  const { routeInfo } = this.props;
2507
2578
  const swipeBackRouteInfo = this.getSwipeBackRouteInfo();
2508
2579
  const enteringViewItem = this.findEnteringViewForSwipe(swipeBackRouteInfo);
@@ -2529,6 +2600,11 @@ class StackManager extends React.PureComponent {
2529
2600
  revealIonPageForSwipeBack(enteringViewItem?.ionPageElement);
2530
2601
  // When the gesture starts, kick off a transition controlled via swipe gesture
2531
2602
  if (enteringViewItem && leavingViewItem) {
2603
+ // Without a leaving view there is no progress animation, and core only calls
2604
+ // swipeHandler.onEnd when one exists, so nothing would clear the mark until the
2605
+ // next transition.
2606
+ markSwipeRevealed(enteringViewItem);
2607
+ this.swipeRevealedViewItem = enteringViewItem;
2532
2608
  await this.transitionPage(routeInfo, enteringViewItem, leavingViewItem, 'back', true);
2533
2609
  }
2534
2610
  debug('SwipeBackOnStart:exit', () => ({
@@ -2553,6 +2629,7 @@ class StackManager extends React.PureComponent {
2553
2629
  const swipeBackRouteInfo = this.getSwipeBackRouteInfo();
2554
2630
  const enteringViewItem = this.findEnteringViewForSwipe(swipeBackRouteInfo);
2555
2631
  const leavingViewItem = this.context.findViewItemByRouteInfo(routeInfo, this.id, false);
2632
+ this.clearSwipeRevealedView();
2556
2633
  // Don't hide if entering and leaving are the same (parameterized route edge case)
2557
2634
  if (enteringViewItem !== leavingViewItem && enteringViewItem?.ionPageElement !== undefined) {
2558
2635
  hideIonPageElement(enteringViewItem.ionPageElement);
@@ -2779,34 +2856,10 @@ class StackManager extends React.PureComponent {
2779
2856
  // Bail out if the component unmounted during waitForComponentsReady
2780
2857
  if (!this._isMounted)
2781
2858
  return;
2782
- const isCurrent = myGeneration === this.transitionGeneration;
2783
- // A page the newest transition is entering is not leaving after all.
2784
- const isLeaving = isCurrent || leavingEl !== this.transitionEnteringElement;
2785
- // Already hidden means the leave events have fired. This checks the class rather
2786
- // than `isViewVisible` because a nested outlet marks its leaving page
2787
- // `visibility: hidden` before we get here and still needs `ionViewDidLeave`.
2788
- const announceLeaving = isLeaving && !leavingEl.classList.contains('ion-page-hidden');
2789
- /**
2790
- * Dispatch the lifecycle events, since we skipped `commit()`. Only the
2791
- * newest transition fires the entering events, and the class swap follows
2792
- * all four so the ordering matches core's `transition()`.
2793
- *
2794
- * These run after `waitForComponentsReady` because on a first mount the
2795
- * page has not attached its listeners yet.
2796
- */
2797
- if (announceLeaving) {
2798
- dispatchLifecycleEvent(leavingEl, 'ionViewWillLeave');
2799
- }
2800
- if (isCurrent) {
2801
- dispatchLifecycleEvent(enteringEl, 'ionViewWillEnter');
2802
- dispatchLifecycleEvent(enteringEl, 'ionViewDidEnter');
2803
- }
2804
- if (announceLeaving) {
2805
- dispatchLifecycleEvent(leavingEl, 'ionViewDidLeave');
2806
- }
2807
2859
  // Swap visibility synchronously - show entering, hide leaving
2860
+ // Skip hiding if a newer transition already made leavingEl the entering view
2808
2861
  enteringEl.classList.remove('ion-page-invisible');
2809
- if (isLeaving) {
2862
+ if (myGeneration === this.transitionGeneration || leavingEl !== this.transitionEnteringElement) {
2810
2863
  leavingEl.classList.add('ion-page-hidden');
2811
2864
  leavingEl.setAttribute('aria-hidden', 'true');
2812
2865
  }