@ionic/react-router 8.8.1-dev.11774273905.1c3b9598 → 8.8.1-dev.11774636992.1511ce70

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
@@ -1406,6 +1406,12 @@ class StackManager extends React.PureComponent {
1406
1406
  this._isMounted = false;
1407
1407
  /** In-flight requestAnimationFrame IDs from transitionPage, cancelled on unmount. */
1408
1408
  this.transitionRafIds = [];
1409
+ /**
1410
+ * Monotonically increasing counter incremented at the start of each transitionPage call.
1411
+ * Used to detect when an async commit() resolves after a newer transition has already run,
1412
+ * preventing the stale commit from hiding an element that the newer transition made visible.
1413
+ */
1414
+ this.transitionGeneration = 0;
1409
1415
  this.outletMountPath = undefined;
1410
1416
  /**
1411
1417
  * Whether this outlet is at the root level (no parent route matches).
@@ -1556,6 +1562,26 @@ class StackManager extends React.PureComponent {
1556
1562
  this.forceUpdate();
1557
1563
  return true;
1558
1564
  }
1565
+ /**
1566
+ * Handles root navigation by unmounting all non-entering views in this outlet.
1567
+ * Fires ionViewWillLeave / ionViewDidLeave only on views that are currently visible.
1568
+ * Views that are mounted but not visible (e.g., pages earlier in the back stack)
1569
+ * are silently unmounted without lifecycle events, consistent with the behavior
1570
+ * of out-of-scope outlet cleanup.
1571
+ */
1572
+ handleRootNavigation(enteringViewItem) {
1573
+ const allViewsInOutlet = this.context.getViewItemsForOutlet(this.id);
1574
+ allViewsInOutlet.forEach((viewItem) => {
1575
+ if (viewItem === enteringViewItem) {
1576
+ return;
1577
+ }
1578
+ if (viewItem.ionPageElement && isViewVisible(viewItem.ionPageElement)) {
1579
+ viewItem.ionPageElement.dispatchEvent(new CustomEvent('ionViewWillLeave', { bubbles: false, cancelable: false }));
1580
+ viewItem.ionPageElement.dispatchEvent(new CustomEvent('ionViewDidLeave', { bubbles: false, cancelable: false }));
1581
+ }
1582
+ this.context.unMountViewItem(viewItem);
1583
+ });
1584
+ }
1559
1585
  /**
1560
1586
  * Handles nested outlet with relative routes but no parent path. Returns true to abort.
1561
1587
  */
@@ -1973,7 +1999,15 @@ class StackManager extends React.PureComponent {
1973
1999
  this._isMounted = true;
1974
2000
  if (this.routerOutletElement) {
1975
2001
  this.setupRouterOutlet(this.routerOutletElement);
1976
- this.handlePageTransition(this.props.routeInfo);
2002
+ // Defer to a microtask to avoid calling forceUpdate() synchronously during
2003
+ // React 19's reappearLayoutEffects phase, which re-runs componentDidMount
2004
+ // without a preceding componentWillUnmount and causes "Maximum update depth exceeded".
2005
+ const routeInfo = this.props.routeInfo;
2006
+ queueMicrotask(() => {
2007
+ if (this._isMounted && this.props.routeInfo.pathname === routeInfo.pathname) {
2008
+ this.handlePageTransition(routeInfo);
2009
+ }
2010
+ });
1977
2011
  }
1978
2012
  }
1979
2013
  componentDidUpdate(prevProps) {
@@ -2044,14 +2078,20 @@ class StackManager extends React.PureComponent {
2044
2078
  // Find entering and leaving view items
2045
2079
  const viewItems = this.findViewItems(routeInfo);
2046
2080
  let enteringViewItem = viewItems.enteringViewItem;
2047
- const leavingViewItem = viewItems.leavingViewItem;
2048
- const shouldUnmountLeavingViewItem = this.shouldUnmountLeavingView(routeInfo, enteringViewItem, leavingViewItem);
2081
+ let leavingViewItem = viewItems.leavingViewItem;
2082
+ let shouldUnmountLeavingViewItem = this.shouldUnmountLeavingView(routeInfo, enteringViewItem, leavingViewItem);
2049
2083
  // Get parent path for nested outlets
2050
2084
  const parentPath = this.getParentPath();
2051
2085
  // Handle out-of-scope outlet (route outside mount path)
2052
2086
  if (this.handleOutOfScopeOutlet(routeInfo)) {
2053
2087
  return;
2054
2088
  }
2089
+ // Handle root navigation: unmount all non-entering views
2090
+ if (routeInfo.routeDirection === 'root') {
2091
+ this.handleRootNavigation(enteringViewItem);
2092
+ leavingViewItem = undefined;
2093
+ shouldUnmountLeavingViewItem = false;
2094
+ }
2055
2095
  // Clear any pending out-of-scope unmount timeout
2056
2096
  if (this.outOfScopeUnmountTimeout) {
2057
2097
  clearTimeout(this.outOfScopeUnmountTimeout);
@@ -2300,6 +2340,7 @@ class StackManager extends React.PureComponent {
2300
2340
  * overlapping content during the transition. Defaults to `false`.
2301
2341
  */
2302
2342
  async transitionPage(routeInfo, enteringViewItem, leavingViewItem, direction, progressAnimation = false, skipAnimation = false) {
2343
+ const myGeneration = ++this.transitionGeneration;
2303
2344
  const runCommit = async (enteringEl, leavingEl) => {
2304
2345
  const skipTransition = this.skipTransition;
2305
2346
  /**
@@ -2349,10 +2390,22 @@ class StackManager extends React.PureComponent {
2349
2390
  const timeoutMs = 5000;
2350
2391
  const timeoutPromise = new Promise((resolve) => setTimeout(() => resolve('timeout'), timeoutMs));
2351
2392
  const result = await Promise.race([commitPromise.then(() => 'done'), timeoutPromise]);
2393
+ // Bail out if the component unmounted during the commit animation
2394
+ if (!this._isMounted)
2395
+ return;
2352
2396
  if (result === 'timeout') {
2353
2397
  // Force entering page visible even though commit hung
2354
2398
  enteringEl.classList.remove('ion-page-invisible');
2355
2399
  }
2400
+ /**
2401
+ * If a newer transitionPage call ran while this commit was in-flight (e.g., a tab
2402
+ * switch fired during a forward animation), the core commit may have applied
2403
+ * ion-page-hidden to leavingEl even though the newer transition already made it
2404
+ * visible. Undo that stale hide so the newer transition's DOM state wins.
2405
+ */
2406
+ if (myGeneration !== this.transitionGeneration && leavingEl && leavingEl === this.transitionEnteringElement) {
2407
+ showIonPageElement(leavingEl);
2408
+ }
2356
2409
  if (!progressAnimation) {
2357
2410
  enteringEl.classList.remove('ion-page-invisible');
2358
2411
  }
@@ -2361,6 +2414,7 @@ class StackManager extends React.PureComponent {
2361
2414
  const routeInfoFallbackDirection = routeInfo.routeDirection === 'none' || routeInfo.routeDirection === 'root' ? undefined : routeInfo.routeDirection;
2362
2415
  const directionToUse = direction !== null && direction !== void 0 ? direction : routeInfoFallbackDirection;
2363
2416
  if (enteringViewItem && enteringViewItem.ionPageElement && this.routerOutletElement) {
2417
+ this.transitionEnteringElement = enteringViewItem.ionPageElement;
2364
2418
  if (leavingViewItem && leavingViewItem.ionPageElement && enteringViewItem === leavingViewItem) {
2365
2419
  // Clone page for same-view transitions (e.g., /user/1 → /user/2)
2366
2420
  const match = matchComponent(leavingViewItem.reactElement, routeInfo.pathname, undefined, this.outletMountPath);
@@ -2461,15 +2515,22 @@ class StackManager extends React.PureComponent {
2461
2515
  if (!this._isMounted)
2462
2516
  return;
2463
2517
  // Swap visibility synchronously - show entering, hide leaving
2518
+ // Skip hiding if a newer transition already made leavingEl the entering view
2464
2519
  enteringEl.classList.remove('ion-page-invisible');
2465
- leavingEl.classList.add('ion-page-hidden');
2466
- leavingEl.setAttribute('aria-hidden', 'true');
2520
+ if (myGeneration === this.transitionGeneration || leavingEl !== this.transitionEnteringElement) {
2521
+ leavingEl.classList.add('ion-page-hidden');
2522
+ leavingEl.setAttribute('aria-hidden', 'true');
2523
+ }
2467
2524
  }
2468
2525
  else {
2469
2526
  await runCommit(enteringViewItem.ionPageElement, leavingEl);
2470
2527
  if (leavingEl && !progressAnimation) {
2471
- leavingEl.classList.add('ion-page-hidden');
2472
- leavingEl.setAttribute('aria-hidden', 'true');
2528
+ // Skip hiding if a newer transition already made leavingEl the entering view
2529
+ // runCommit's generation check has already restored its visibility in that case
2530
+ if (myGeneration === this.transitionGeneration || leavingEl !== this.transitionEnteringElement) {
2531
+ leavingEl.classList.add('ion-page-hidden');
2532
+ leavingEl.setAttribute('aria-hidden', 'true');
2533
+ }
2473
2534
  }
2474
2535
  }
2475
2536
  }
@@ -2769,34 +2830,14 @@ const IonRouter = ({ children, registerHistoryListener }) => {
2769
2830
  * @param action The action that triggered the history change.
2770
2831
  */
2771
2832
  const handleHistoryChange = (location, action) => {
2772
- var _a, _b, _c, _d, _e;
2773
- let leavingLocationInfo;
2833
+ var _a, _b, _c, _d;
2774
2834
  /**
2775
- * A programmatic navigation was triggered.
2776
- * e.g., `<Navigate />`, `navigate()`, or `handleNavigate()`
2835
+ * The leaving location is always the current route, for both programmatic
2836
+ * and external navigations. Using `previous()` for replace actions was
2837
+ * incorrect: it caused the equality check below to skip navigation when
2838
+ * the replace destination matched the entry two slots back in history.
2777
2839
  */
2778
- if (incomingRouteParams.current) {
2779
- /**
2780
- * The current history entry is overwritten, so the previous entry
2781
- * is the one we are leaving.
2782
- */
2783
- if (((_a = incomingRouteParams.current) === null || _a === void 0 ? void 0 : _a.routeAction) === 'replace') {
2784
- leavingLocationInfo = locationHistory.current.previous();
2785
- }
2786
- else {
2787
- // If the action is 'push' or 'pop', we want to use the current route.
2788
- leavingLocationInfo = locationHistory.current.current();
2789
- }
2790
- }
2791
- else {
2792
- /**
2793
- * An external navigation was triggered
2794
- * e.g., browser back/forward button or direct link
2795
- *
2796
- * The leaving location is the current route.
2797
- */
2798
- leavingLocationInfo = locationHistory.current.current();
2799
- }
2840
+ const leavingLocationInfo = locationHistory.current.current();
2800
2841
  const leavingUrl = leavingLocationInfo.pathname + leavingLocationInfo.search;
2801
2842
  if (leavingUrl !== location.pathname + location.search) {
2802
2843
  if (!incomingRouteParams.current) {
@@ -2898,7 +2939,7 @@ const IonRouter = ({ children, registerHistoryListener }) => {
2898
2939
  * An existing id indicates that it's re-activating an existing route.
2899
2940
  * e.g., tab switching or navigating back to a previous route
2900
2941
  */
2901
- if ((_b = incomingRouteParams.current) === null || _b === void 0 ? void 0 : _b.id) {
2942
+ if ((_a = incomingRouteParams.current) === null || _a === void 0 ? void 0 : _a.id) {
2902
2943
  routeInfo = Object.assign(Object.assign({}, incomingRouteParams.current), { lastPathname: leavingLocationInfo.pathname });
2903
2944
  locationHistory.current.add(routeInfo);
2904
2945
  /**
@@ -2907,9 +2948,9 @@ const IonRouter = ({ children, registerHistoryListener }) => {
2907
2948
  */
2908
2949
  }
2909
2950
  else {
2910
- const isPushed = ((_c = incomingRouteParams.current) === null || _c === void 0 ? void 0 : _c.routeAction) === 'push' &&
2951
+ const isPushed = ((_b = incomingRouteParams.current) === null || _b === void 0 ? void 0 : _b.routeAction) === 'push' &&
2911
2952
  incomingRouteParams.current.routeDirection === 'forward';
2912
- routeInfo = Object.assign(Object.assign({ id: generateId('routeInfo') }, incomingRouteParams.current), { lastPathname: leavingLocationInfo.pathname, pathname: location.pathname, search: location.search, params: ((_d = incomingRouteParams.current) === null || _d === void 0 ? void 0 : _d.params)
2953
+ routeInfo = Object.assign(Object.assign({ id: generateId('routeInfo') }, incomingRouteParams.current), { lastPathname: leavingLocationInfo.pathname, pathname: location.pathname, search: location.search, params: ((_c = incomingRouteParams.current) === null || _c === void 0 ? void 0 : _c.params)
2913
2954
  ? filterUndefinedParams(incomingRouteParams.current.params)
2914
2955
  : {}, prevRouteLastPathname: leavingLocationInfo.lastPathname });
2915
2956
  if (isPushed) {
@@ -2949,7 +2990,7 @@ const IonRouter = ({ children, registerHistoryListener }) => {
2949
2990
  routeInfo.pushedByRoute = lastRoute === null || lastRoute === void 0 ? void 0 : lastRoute.pushedByRoute;
2950
2991
  }
2951
2992
  else {
2952
- routeInfo.pushedByRoute = (_e = lastRoute === null || lastRoute === void 0 ? void 0 : lastRoute.pushedByRoute) !== null && _e !== void 0 ? _e : leavingLocationInfo.pathname;
2993
+ routeInfo.pushedByRoute = (_d = lastRoute === null || lastRoute === void 0 ? void 0 : lastRoute.pushedByRoute) !== null && _d !== void 0 ? _d : leavingLocationInfo.pathname;
2953
2994
  }
2954
2995
  // Triggered by `navigate()` with replace or a `<Navigate />` component, etc.
2955
2996
  }
@@ -3058,6 +3099,7 @@ const IonRouter = ({ children, registerHistoryListener }) => {
3058
3099
  *
3059
3100
  * @param tab The tab to set as active.
3060
3101
  */
3102
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
3061
3103
  const handleSetCurrentTab = (tab, _routeInfo) => {
3062
3104
  currentTab.current = tab;
3063
3105
  const current = locationHistory.current.current();
@@ -3197,6 +3239,24 @@ const IonRouter = ({ children, registerHistoryListener }) => {
3197
3239
  routeAnimation, tab: navigationTab });
3198
3240
  navigate(path, { replace: routeAction !== 'push' });
3199
3241
  };
3242
+ /**
3243
+ * Navigates to a new root path, clearing Ionic's navigation history so that
3244
+ * canGoBack() returns false after the transition. All previously mounted views
3245
+ * are unmounted. Useful for post-login / post-logout root navigation.
3246
+ *
3247
+ * @param pathname The path to navigate to.
3248
+ * @param routeAnimation An optional custom animation builder.
3249
+ */
3250
+ const handleNavigateRoot = (pathname, routeAnimation) => {
3251
+ currentTab.current = undefined;
3252
+ forwardStack.current = [];
3253
+ incomingRouteParams.current = {
3254
+ routeAction: 'replace',
3255
+ routeDirection: 'root',
3256
+ routeAnimation,
3257
+ };
3258
+ navigate(pathname, { replace: true });
3259
+ };
3200
3260
  const routeMangerContextValue = {
3201
3261
  canGoBack: () => locationHistory.current.canGoBack(),
3202
3262
  clearOutlet: viewStack.current.clear,
@@ -3211,7 +3271,7 @@ const IonRouter = ({ children, registerHistoryListener }) => {
3211
3271
  unMountViewItem: viewStack.current.remove,
3212
3272
  };
3213
3273
  return (React.createElement(RouteManagerContext.Provider, { value: routeMangerContextValue },
3214
- React.createElement(NavManager, { ionRoute: IonRouteInner, stackManager: StackManager, routeInfo: routeInfo, onNativeBack: handleNativeBack, onNavigateBack: handleNavigateBack, onNavigate: handleNavigate, onSetCurrentTab: handleSetCurrentTab, onChangeTab: handleChangeTab, onResetTab: handleResetTab, locationHistory: locationHistory.current }, children)));
3274
+ React.createElement(NavManager, { ionRoute: IonRouteInner, stackManager: StackManager, routeInfo: routeInfo, onNativeBack: handleNativeBack, onNavigateBack: handleNavigateBack, onNavigate: handleNavigate, onNavigateRoot: handleNavigateRoot, onSetCurrentTab: handleSetCurrentTab, onChangeTab: handleChangeTab, onResetTab: handleResetTab, locationHistory: locationHistory.current }, children)));
3215
3275
  };
3216
3276
  IonRouter.displayName = 'IonRouter';
3217
3277