chore(react-router): minor clean up

This commit is contained in:
ShaneK
2025-12-02 13:09:40 -08:00
parent 418ac75501
commit 99dcb356df
8 changed files with 148 additions and 122 deletions

View File

@@ -2,18 +2,18 @@
**Branch:** `sk/react-router-6`
**Design Docs:** [PR #305](https://github.com/ionic-team/ionic-framework-design-documents/pull/305)
**Last Updated:** November 26, 2025
**Last Updated:** December 2, 2025
## Overview
The `@ionic/react-router` package has been updated to support React Router 6. This migration replaces the React Router 5 integration with native RR6 APIs while preserving Ionic's navigation patterns, animations, and view lifecycle management.
All Cypress tests are now passing.
All Cypress tests are passing.
| Metric | Count |
|--------|-------|
| Total tests | 70 |
| Passing | 70 |
| Total tests | 77 |
| Passing | 77 |
| Failing | 0 |
## What Changed
@@ -29,31 +29,53 @@ The `@ionic/react-router` package now requires React Router 6:
}
```
The `history` package dependency was updated from v4 to v5 (which RR6 uses internally).
### Core Components
**IonRouter** was rewritten as a functional component using React hooks. It now uses `useLocation` and `useNavigate` from React Router 6 instead of the `withRouter` HOC and `history` object from v5. The component continues to manage `LocationHistory` and compute `RouteInfo` objects for Ionic's view stacks and transition directions.
**IonRouter** (`IonRouter.tsx`) was rewritten as a functional component using React hooks. It now uses `useLocation` and `useNavigate` from React Router 6 instead of the `withRouter` HOC and `history` object from v5. The component continues to manage `LocationHistory` and compute `RouteInfo` objects for Ionic's view stacks and transition directions.
**ReactRouterViewStack** was substantially expanded to handle RR6's matching semantics. Key additions include:
**ReactRouterViewStack** (`ReactRouterViewStack.tsx`) was substantially expanded to handle RR6's matching semantics. Key changes include:
- Support for RR6's `PathMatch` objects and pattern matching
- Handling of index routes and wildcard routes (`*`)
- View identity tracking for parameterized routes (`/user/:id`)
- Proper computation of parent paths for nested outlets
- View cleanup for cross-navigation scenarios (e.g., navigating between different tab stacks)
**StackManager** was updated to work with the new view stack implementation. Changes include:
**StackManager** (`StackManager.tsx`) was updated to work with the new view stack implementation. Changes include:
- Parent path derivation using RR6's route matching
- Improved handling of `Navigate` redirect components
- Better coordination of entering/leaving views during transitions
- Hiding of deactivated catch-all routes to prevent visual glitches
**IonRouteInner** (`IonRouteInner.tsx`) was simplified to work with RR6's element-based routing instead of the component/render prop pattern.
**Router Components** (`IonReactRouter.tsx`, `IonReactHashRouter.tsx`, `IonReactMemoryRouter.tsx`) were updated to use RR6's router components and hooks.
### New Utilities
Four utility modules were added to support RR6's routing model:
Five utility modules were added to support RR6's routing model:
| File | Purpose |
|------|---------|
| `matchPath.ts` | Extended path matching with RR6 pattern syntax |
| `matchRoutesFromChildren.ts` | Converts Route children to RouteObjects for RR6's `matchRoutes` |
| `derivePathnameToMatch.ts` | Computes the pathname portion relevant to a nested outlet |
| `findRoutesNode.ts` | Locates Routes containers in the component tree |
| `computeParentPath.ts` | Computes common path prefixes and determines specific route matches for nested outlets |
| `pathMatching.ts` | Extended path matching using RR6's `matchPath` with support for index routes |
| `pathNormalization.ts` | Path string normalization (leading/trailing slashes) |
| `routeElements.ts` | Extracts Route children from Routes wrappers, detects Navigate elements |
| `viewItemUtils.ts` | Sorts views by route specificity for proper matching priority |
The old `matchPath.ts` utility was removed as its functionality is now handled by RR6's native matching.
### @ionic/react Changes
Several changes were made to the `@ionic/react` package to support the migration:
- **IonRoute** (`IonRoute.tsx`): Updated to work with RR6's element-based routing
- **IonRouterOutlet** (`IonRouterOutlet.tsx`): Updated for RR6 compatibility
- **LocationHistory** (`LocationHistory.ts`): Enhanced to track navigation direction more accurately
- **RouteManagerContext** (`RouteManagerContext.ts`): Added `clearOutletViews` method for cross-navigation cleanup
- **ViewLifeCycleManager** (`ViewLifeCycleManager.tsx`): Added support for new lifecycle events
- **createInlineOverlayComponent** (`createInlineOverlayComponent.tsx`): New utility to automatically dismiss inline overlays on navigation
### Test App Updates
@@ -62,6 +84,11 @@ All test pages in `packages/react-router/test/base/src/pages/` were updated to u
- Nested routes now require trailing wildcards (`path="parent/*"`) when they contain child outlets
- `<Redirect to="..." />` became `<Navigate to="..." replace />`
- Route params accessed via `useParams()` instead of `props.match.params`
- Links use relative paths where appropriate
A new test page (`nested-params/NestedParams.tsx`) was added to test parameterized nested routing scenarios.
The `reactrouter5` test app was removed since the package no longer supports RR5.
## Test Coverage
@@ -72,17 +99,26 @@ The Cypress test suite covers the following scenarios:
| routing.cy.js | 29 | Core navigation, tabs, back button, redirects, params |
| nested-outlets.cy.js | 11 | Nested `IonRouterOutlet` behavior, back navigation |
| swipe-to-go-back.cy.js | 8 | Gesture navigation, abort handling, tab interactions |
| cross-route-navigation.cy.js | 7 | Navigation between different route contexts (tabs, outlets) |
| multiple-tabs.cy.js | 4 | Switching between different tab configurations |
| dynamic-tabs.cy.js | 3 | Adding tabs at runtime |
| dynamic-routes.cy.js | 3 | Adding routes at runtime |
| overlays.cy.js | 3 | Modal cleanup on navigation |
| refs.cy.js | 2 | Ref forwarding to Ionic components |
| dynamic-routes.cy.js | 3 | Adding routes at runtime |
| dynamic-tabs.cy.js | 3 | Adding tabs at runtime |
| tabs.cy.js | 2 | Basic tab navigation and history |
| tab-context.cy.js | 2 | Programmatic tab switching via context |
| refs.cy.js | 2 | Ref forwarding to Ionic components |
| dynamic-ionpage-classnames.cy.js | 1 | Dynamic class application to IonPage |
| outlet-ref.cy.js | 1 | Ref access to IonRouterOutlet |
| replace-actions.cy.js | 1 | History replacement behavior |
### New Test Suite: Cross-Route Navigation
A new test suite (`cross-route-navigation.cy.js`) was added to verify proper view cleanup when navigating between different route contexts:
- Tab-to-non-tab navigation
- Non-tab-to-tab navigation
- Between different tab configurations
- Deep link scenarios
## Known Limitations
### Route Path Syntax
@@ -99,12 +135,65 @@ Nested outlets require parent routes to include a trailing wildcard:
This aligns with React Router 6's nested routing semantics where child routes are matched relative to the parent's path.
### Relative vs Absolute Paths
React Router 6 strongly favors relative paths for nested routing. While absolute paths still work, using relative paths in nested outlets is recommended:
```tsx
// Inside a component at /tabs/home
<Link to="details">Details</Link> // Navigates to /tabs/home/details
<Link to="/tabs/home/details">Details</Link> // Also works, but less flexible
```
## CI Integration
The GitHub Actions workflows have been updated to run the RR6 test app:
- `.github/workflows/build.yml`
- `.github/workflows/stencil-nightly.yml`
The matrix now uses `reactrouter6` instead of `reactrouter5`.
## Architecture Reference
The data flow through the routing system:
```
Browser History Change
|
v
IonRouter (useLocation/useNavigate)
|
+-- Updates LocationHistory
+-- Computes RouteInfo (action, direction, params)
|
v
RouteManagerContext
|
v
StackManager (per IonRouterOutlet)
|
+-- Derives parent path from route children
+-- Matches routes using ReactRouterViewStack
+-- Determines entering/leaving views
+-- Clears stale views on cross-navigation
|
v
ion-router-outlet.commit()
|
v
Native Ionic Transition
```
The key insight is that Ionic intercepts React Router's navigation events and translates them into its own view management system, which enables native-feeling animations and gestures while still using React Router for URL management.
## Next Steps
### Before Alpha Release
### Before Release
1. ~~**Run a TypeScript strict check** on the `@ionic/react-router` package~~ Complete - all type errors resolved
2. **Manual testing pass** through the test app to verify animations and gestures feel correct
1. ~~**Run a TypeScript strict check** on the `@ionic/react-router` package~~ Complete
2. ~~**All Cypress tests passing**~~ Complete (77/77)
3. **Manual testing pass** through the test app to verify animations and gestures feel correct
4. **Code review** of the implementation
### Documentation
@@ -113,7 +202,8 @@ The following documentation should be prepared before public release:
1. **Migration guide** covering:
- Route syntax changes (`component` to `element`, `Redirect` to `Navigate`)
- Nested route wildcard requirements
- Accessing route params with hooks
- Accessing route params with hooks (`useParams()`)
- Link syntax changes (relative paths)
- Any removed or deprecated APIs
2. **Updated API reference** for:
@@ -121,52 +211,42 @@ The following documentation should be prepared before public release:
- `IonRouterOutlet` behavior with nested routes
- `routeOptions.unmount` and `LocationHistory` behavior
### CI Integration
The GitHub Actions workflows have been updated to run the RR6 test app:
- `.github/workflows/build.yml`
- `.github/workflows/stencil-nightly.yml`
## Architecture Reference
The data flow through the routing system:
```
Browser History Change
│
▼
IonRouter (useLocation/useNavigate)
│
├── Updates LocationHistory
├── Computes RouteInfo (action, direction, params)
│
▼
RouteManagerContext
│
▼
StackManager (per IonRouterOutlet)
│
├── Derives parent path from route children
├── Matches routes using ReactRouterViewStack
├── Determines entering/leaving views
│
▼
ion-router-outlet.commit()
│
▼
Native Ionic Transition
```
The key insight is that Ionic intercepts React Router's navigation events and translates them into its own view management system, which enables native-feeling animations and gestures while still using React Router for URL management.
## Related Files
Source code:
- `packages/react-router/src/ReactRouter/IonRouter.tsx`
- `packages/react-router/src/ReactRouter/ReactRouterViewStack.tsx`
- `packages/react-router/src/ReactRouter/StackManager.tsx`
- `packages/react-router/src/ReactRouter/utils/`
- `packages/react-router/src/ReactRouter/IonRouteInner.tsx`
- `packages/react-router/src/ReactRouter/IonReactRouter.tsx`
- `packages/react-router/src/ReactRouter/IonReactHashRouter.tsx`
- `packages/react-router/src/ReactRouter/IonReactMemoryRouter.tsx`
- `packages/react-router/src/ReactRouter/utils/` (5 utility modules)
Test app:
Test infrastructure:
- `packages/react-router/test/base/` (shared test code)
- `packages/react-router/test/apps/reactrouter6/` (RR6 config)
- `packages/react-router/scripts/test_runner.sh` (test automation)
Supporting changes in @ionic/react:
- `packages/react/src/components/IonRoute.tsx`
- `packages/react/src/components/IonRouterOutlet.tsx`
- `packages/react/src/components/createInlineOverlayComponent.tsx`
- `packages/react/src/routing/LocationHistory.ts`
- `packages/react/src/routing/RouteManagerContext.ts`
- `packages/react/src/routing/ViewLifeCycleManager.tsx`
## Commit History
Key commits on this branch:
| Commit | Description |
|--------|-------------|
| `418ac75` | Cleaning up util files |
| `82fd1ba` | Fix views not being cleaned up properly, causing cross navigation issues |
| `3912623` | Hide deactivated catch-all routes |
| `7fd0659` | Nested redirect fix |
| `b02c197` | Prevent incorrect view reuse for parameterized routes |
| `584dcf2` | Prioritize specific route matches |
| `045b0a7` | Correct tab and nested outlet navigation |
| `59f2dbe` | Automatically dismiss inline overlays on navigation |

View File

@@ -6,7 +6,7 @@
* and animate.
*/
import type { AnimationBuilder, RouteAction, RouteInfo, RouteManagerContextState, RouterDirection } from '@ionic/react';
import type { AnimationBuilder, RouteAction, RouteInfo, RouteManagerContextState, RouterDirection, RouterOptions } from '@ionic/react';
import { LocationHistory, NavManager, RouteManagerContext, generateId, getConfig } from '@ionic/react';
import type { Action as HistoryAction, Location } from 'history';
import type { PropsWithChildren } from 'react';
@@ -22,7 +22,7 @@ type HistoryLocation = Location;
export interface LocationState {
direction?: RouterDirection;
routerOptions?: { as?: string; unmount?: boolean };
routerOptions?: RouterOptions;
}
interface IonRouterProps {

View File

@@ -54,8 +54,8 @@ const Tab1: React.FC = () => {
<IonItem routerLink="/routing/tabs/home/details/1">
<IonLabel>Details 1</IonLabel>
</IonItem>
<IonItem routerLink="/routing/tabs/home/details/1" routerOptions={{ unmount: true }}>
<IonLabel>Details 1 & Unmount</IonLabel>
<IonItem routerLink="/routing/tabs/home/details/1">
<IonLabel>Details 1 (alt)</IonLabel>
</IonItem>
<IonItem routerLink="/routing/tabs/home/details/1?hello=there">
<IonLabel>Details 1 with Query Params</IonLabel>

View File

@@ -34,15 +34,15 @@ const Tabs: React.FC = () => {
/>
</IonRouterOutlet>
<IonTabBar slot="bottom">
<IonTabButton tab="home" href="/routing/tabs/home" routerOptions={{ unmount: true }}>
<IonTabButton tab="home" href="/routing/tabs/home">
<IonIcon icon={triangle} />
<IonLabel>Home</IonLabel>
</IonTabButton>
<IonTabButton tab="settings" href="/routing/tabs/settings" routerOptions={{ unmount: true }}>
<IonTabButton tab="settings" href="/routing/tabs/settings">
<IonIcon icon={ellipse} />
<IonLabel>Settings</IonLabel>
</IonTabButton>
<IonTabButton tab="tab3" href="/routing/tabs/tab3" routerOptions={{ unmount: true }}>
<IonTabButton tab="tab3" href="/routing/tabs/tab3">
<IonIcon icon={square} />
<IonLabel>Tab 3</IonLabel>
</IonTabButton>

View File

@@ -28,7 +28,7 @@ const TabsContext: React.FC = () => {
<Route path="tab2" element={<Tab2 />} />
</IonRouterOutlet>
<IonTabBar slot="bottom">
<IonTabButton tab="tab1" href="/tab-context/tab1" routerOptions={{ unmount: true }}>
<IonTabButton tab="tab1" href="/tab-context/tab1">
<IonIcon icon={triangle} />
<IonLabel>Tab1</IonLabel>
</IonTabButton>

View File

@@ -1,45 +0,0 @@
import type { Location as HistoryLocation } from 'history';
const RESTRICT_SIZE = 25;
export class LocationHistory {
private locationHistory: HistoryLocation[] = [];
add(location: HistoryLocation) {
this.locationHistory.push(location);
if (this.locationHistory.length > RESTRICT_SIZE) {
this.locationHistory.splice(0, 10);
}
}
pop() {
this.locationHistory.pop();
}
replace(location: HistoryLocation) {
this.locationHistory.pop();
this.locationHistory.push(location);
}
clear() {
this.locationHistory = [];
}
findLastLocationByUrl(url: string) {
for (let i = this.locationHistory.length - 1; i >= 0; i--) {
const location = this.locationHistory[i];
if (location.pathname.toLocaleLowerCase() === url.toLocaleLowerCase()) {
return location;
}
}
return undefined;
}
previous() {
return this.locationHistory[this.locationHistory.length - 2];
}
current() {
return this.locationHistory[this.locationHistory.length - 1];
}
}

View File

@@ -86,10 +86,7 @@ export const createInlineOverlayComponent = <PropType, ElementType>(
if (mutation.type === 'attributes' && mutation.attributeName === 'class') {
const target = mutation.target as HTMLElement;
// If any element gets the ion-page-hidden or ion-page-invisible class, dismiss overlay
if (
target.classList.contains('ion-page-hidden') ||
target.classList.contains('ion-page-invisible')
) {
if (target.classList.contains('ion-page-hidden') || target.classList.contains('ion-page-invisible')) {
this.dismissOverlay();
return;
}

View File

@@ -40,12 +40,6 @@ export class ViewLifeCycleManager extends React.Component<ViewTransitionManagerP
this._isMounted = true;
}
componentDidUpdate(_prevProps: ViewTransitionManagerProps) {
// View lifecycle is managed through ionViewDidLeave events.
// Components with IonPage will receive these events and be destroyed accordingly.
// The StackManager handles cleanup of views that no longer match routes.
}
componentWillUnmount() {
this._isMounted = false;
}