diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
index 3c423cc569e..999ece0f602 100644
--- a/.github/workflows/build.yml
+++ b/.github/workflows/build.yml
@@ -176,7 +176,7 @@ jobs:
strategy:
fail-fast: false
matrix:
- apps: [reactrouter5]
+ apps: [reactrouter6]
needs: [build-react, build-react-router]
runs-on: ubuntu-latest
steps:
diff --git a/.github/workflows/stencil-nightly.yml b/.github/workflows/stencil-nightly.yml
index 6af9474e350..fe045ac77cc 100644
--- a/.github/workflows/stencil-nightly.yml
+++ b/.github/workflows/stencil-nightly.yml
@@ -186,7 +186,7 @@ jobs:
strategy:
fail-fast: false
matrix:
- apps: [reactrouter5]
+ apps: [reactrouter6]
needs: [build-react, build-react-router]
runs-on: ubuntu-latest
steps:
diff --git a/BREAKING.md b/BREAKING.md
index bf44f563dc8..8e977147746 100644
--- a/BREAKING.md
+++ b/BREAKING.md
@@ -4,6 +4,7 @@ This is a comprehensive list of the breaking changes introduced in the major ver
## Versions
+- [Version 9.x](#version-9x)
- [Version 8.x](#version-8x)
- [Version 7.x](./BREAKING_ARCHIVE/v7.md)
- [Version 6.x](./BREAKING_ARCHIVE/v6.md)
@@ -11,6 +12,68 @@ This is a comprehensive list of the breaking changes introduced in the major ver
- [Version 4.x](./BREAKING_ARCHIVE/v4.md)
- [Legacy](https://github.com/ionic-team/ionic-v3/blob/master/CHANGELOG.md)
+## Version 9.x
+
+- [Framework Specific](#version-9x-framework-specific)
+ - [React](#version-9x-react)
+
+
Framework Specific
+
+React
+
+The `@ionic/react-router` package now requires React Router v6. React Router v5 is no longer supported.
+
+**Minimum Version Requirements**
+| Package | Supported Version |
+| ---------------- | ----------------- |
+| react-router | 6.0.0+ |
+| react-router-dom | 6.0.0+ |
+
+React Router v6 introduces several API changes that will require updates to your application's routing configuration:
+
+**Route Definition Changes**
+
+The `component` prop has been replaced with the `element` prop, which accepts JSX:
+
+```diff
+-
++ } />
+```
+
+**Redirect Changes**
+
+The `` component has been replaced with ``:
+
+```diff
+- import { Redirect } from 'react-router-dom';
++ import { Navigate } from 'react-router-dom';
+
+-
++
+```
+
+**Nested Route Paths**
+
+Routes that contain nested routes or child `IonRouterOutlet` components need a `/*` suffix to match sub-paths:
+
+```diff
+- } />
++ } />
+```
+
+**Accessing Route Parameters**
+
+Route parameters are now accessed via the `useParams` hook instead of props:
+
+```diff
+- const MyComponent: React.FC> = ({ match }) => {
+- const id = match.params.id;
++ const MyComponent: React.FC = () => {
++ const { id } = useParams<{ id: string }>();
+```
+
+For more information on migrating from React Router v5 to v6, refer to the [React Router v6 Upgrade Guide](https://reactrouter.com/en/main/upgrading/v5).
+
## Version 8.x
- [Browser and Platform Support](#version-8x-browser-platform-support)
diff --git a/package-lock.json b/package-lock.json
index d071e527ba9..54ec41085ba 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -9288,8 +9288,7 @@
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/@octokit/plugin-request-log/-/plugin-request-log-1.0.4.tgz",
"integrity": "sha512-mLUsMkgP7K/cnFEw07kWqXGF5LKrOkD+lhCrKvPHXWDywAwuDUeDwWBpc69XK3pNX0uKiVt8g5z96PJ6z9xCFA==",
- "dev": true,
- "requires": {}
+ "dev": true
},
"@octokit/plugin-rest-endpoint-methods": {
"version": "6.6.2",
diff --git a/packages/react-router/package-lock.json b/packages/react-router/package-lock.json
index ca60000c58b..bc851f3a77b 100644
--- a/packages/react-router/package-lock.json
+++ b/packages/react-router/package-lock.json
@@ -19,16 +19,15 @@
"@types/node": "^14.0.14",
"@types/react": "^17.0.79",
"@types/react-dom": "^17.0.25",
- "@types/react-router": "^5.0.3",
- "@types/react-router-dom": "^5.1.5",
"@typescript-eslint/eslint-plugin": "^5.48.2",
"@typescript-eslint/parser": "^5.48.2",
"eslint": "^7.32.0",
+ "history": "^5.3.0",
"prettier": "^2.8.3",
"react": "^17.0.2",
"react-dom": "^17.0.2",
- "react-router": "^5.0.1",
- "react-router-dom": "^5.0.1",
+ "react-router": "^6.30.0",
+ "react-router-dom": "^6.30.0",
"rimraf": "^3.0.2",
"rollup": "^4.2.0",
"typescript": "^4.0.5"
@@ -36,8 +35,8 @@
"peerDependencies": {
"react": ">=16.8.6",
"react-dom": ">=16.8.6",
- "react-router": "^5.0.1",
- "react-router-dom": "^5.0.1"
+ "react-router": ">=6.0.0",
+ "react-router-dom": ">=6.0.0"
}
},
"node_modules/@aashutoshrathi/word-wrap": {
@@ -153,13 +152,11 @@
}
},
"node_modules/@babel/runtime": {
- "version": "7.23.2",
- "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.23.2.tgz",
- "integrity": "sha512-mM8eg4yl5D6i3lu2QKPuPH4FArvJ8KhTofbE7jwMUv9KX5mBvwPAqnV3MlyBNqdp9RyRKP6Yck8TrfYrPvX3bg==",
+ "version": "7.28.4",
+ "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.4.tgz",
+ "integrity": "sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ==",
"dev": true,
- "dependencies": {
- "regenerator-runtime": "^0.14.0"
- },
+ "license": "MIT",
"engines": {
"node": ">=6.9.0"
}
@@ -464,6 +461,15 @@
"node": ">= 8"
}
},
+ "node_modules/@remix-run/router": {
+ "version": "1.23.0",
+ "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.0.tgz",
+ "integrity": "sha512-O3rHJzAQKamUz1fvE0Qaw0xSFqsA/yafi2iqeE0pvdFtCO1viYx8QL6f3Ln/aCCTLxs68SLf0KPM9eSeM8yBnA==",
+ "dev": true,
+ "engines": {
+ "node": ">=14.0.0"
+ }
+ },
"node_modules/@rollup/plugin-typescript": {
"version": "11.1.5",
"resolved": "https://registry.npmjs.org/@rollup/plugin-typescript/-/plugin-typescript-11.1.5.tgz",
@@ -801,12 +807,6 @@
"integrity": "sha512-2JwWnHK9H+wUZNorf2Zr6ves96WHoWDJIftkcxPKsS7Djta6Zu519LarhRNljPXkpsZR2ZMwNCPeW7omW07BJw==",
"dev": true
},
- "node_modules/@types/history": {
- "version": "4.7.11",
- "resolved": "https://registry.npmjs.org/@types/history/-/history-4.7.11.tgz",
- "integrity": "sha512-qjDJRrmvBMiTx+jyLxvLfJU7UznFuokDv4f3WRuriHKERccVpFU+8XMQUAbDzoiJCsmexxRExQeMwwCdamSKDA==",
- "dev": true
- },
"node_modules/@types/json-schema": {
"version": "7.0.14",
"resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.14.tgz",
@@ -851,27 +851,6 @@
"@types/react": "^17"
}
},
- "node_modules/@types/react-router": {
- "version": "5.1.20",
- "resolved": "https://registry.npmjs.org/@types/react-router/-/react-router-5.1.20.tgz",
- "integrity": "sha512-jGjmu/ZqS7FjSH6owMcD5qpq19+1RS9DeVRqfl1FeBMxTDQAGwlMWOcs52NDoXaNKyG3d1cYQFMs9rCrb88o9Q==",
- "dev": true,
- "dependencies": {
- "@types/history": "^4.7.11",
- "@types/react": "*"
- }
- },
- "node_modules/@types/react-router-dom": {
- "version": "5.3.3",
- "resolved": "https://registry.npmjs.org/@types/react-router-dom/-/react-router-dom-5.3.3.tgz",
- "integrity": "sha512-kpqnYK4wcdm5UaWI3fLcELopqLrHgLqNsdpHauzlQktfkHL3npOSwtj1Uz9oKBAzs7lFtVkV8j83voAz2D8fhw==",
- "dev": true,
- "dependencies": {
- "@types/history": "^4.7.11",
- "@types/react": "*",
- "@types/react-router": "*"
- }
- },
"node_modules/@types/scheduler": {
"version": "0.16.8",
"resolved": "https://registry.npmjs.org/@types/scheduler/-/scheduler-0.16.8.tgz",
@@ -2455,26 +2434,13 @@
}
},
"node_modules/history": {
- "version": "4.10.1",
- "resolved": "https://registry.npmjs.org/history/-/history-4.10.1.tgz",
- "integrity": "sha512-36nwAD620w12kuzPAsyINPWJqlNbij+hpK1k9XRloDtym8mxzGYl2c17LnV6IAGB2Dmg4tEa7G7DlawS0+qjew==",
- "dev": true,
- "dependencies": {
- "@babel/runtime": "^7.1.2",
- "loose-envify": "^1.2.0",
- "resolve-pathname": "^3.0.0",
- "tiny-invariant": "^1.0.2",
- "tiny-warning": "^1.0.0",
- "value-equal": "^1.0.1"
- }
- },
- "node_modules/hoist-non-react-statics": {
- "version": "3.3.2",
- "resolved": "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz",
- "integrity": "sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==",
+ "version": "5.3.0",
+ "resolved": "https://registry.npmjs.org/history/-/history-5.3.0.tgz",
+ "integrity": "sha512-ZqaKwjjrAYUYfLG+htGaIIZ4nioX2L70ZUMIFysS3xvBsSG4x/n1V6TXV3N8ZYNuFGlDirFg32T7B6WOUPDYcQ==",
"dev": true,
+ "license": "MIT",
"dependencies": {
- "react-is": "^16.7.0"
+ "@babel/runtime": "^7.7.6"
}
},
"node_modules/ignore": {
@@ -2782,12 +2748,6 @@
"url": "https://github.com/sponsors/ljharb"
}
},
- "node_modules/isarray": {
- "version": "0.0.1",
- "resolved": "https://registry.npmjs.org/isarray/-/isarray-0.0.1.tgz",
- "integrity": "sha512-D2S+3GLxWH+uhrNEcoh/fnmYeP8E8/zHl644d/jdA0g2uyXvy3sb0qxotE+ne0LtccHknQzWwZEzhak7oJ0COQ==",
- "dev": true
- },
"node_modules/isexe": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
@@ -3112,15 +3072,6 @@
"integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==",
"dev": true
},
- "node_modules/path-to-regexp": {
- "version": "1.8.0",
- "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-1.8.0.tgz",
- "integrity": "sha512-n43JRhlUKUAlibEJhPeir1ncUID16QnEjNpwzNdO3Lm4ywrBpBZ5oLD0I6br9evr1Y9JTqwRtAh7JLoOzAQdVA==",
- "dev": true,
- "dependencies": {
- "isarray": "0.0.1"
- }
- },
"node_modules/path-type": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz",
@@ -3175,17 +3126,6 @@
"node": ">=0.4.0"
}
},
- "node_modules/prop-types": {
- "version": "15.8.1",
- "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
- "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==",
- "dev": true,
- "dependencies": {
- "loose-envify": "^1.4.0",
- "object-assign": "^4.1.1",
- "react-is": "^16.13.1"
- }
- },
"node_modules/punycode": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
@@ -3240,56 +3180,38 @@
"react": "17.0.2"
}
},
- "node_modules/react-is": {
- "version": "16.13.1",
- "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
- "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
- "dev": true
- },
"node_modules/react-router": {
- "version": "5.3.4",
- "resolved": "https://registry.npmjs.org/react-router/-/react-router-5.3.4.tgz",
- "integrity": "sha512-Ys9K+ppnJah3QuaRiLxk+jDWOR1MekYQrlytiXxC1RyfbdsZkS5pvKAzCCr031xHixZwpnsYNT5xysdFHQaYsA==",
+ "version": "6.30.0",
+ "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.30.0.tgz",
+ "integrity": "sha512-D3X8FyH9nBcTSHGdEKurK7r8OYE1kKFn3d/CF+CoxbSHkxU7o37+Uh7eAHRXr6k2tSExXYO++07PeXJtA/dEhQ==",
"dev": true,
"dependencies": {
- "@babel/runtime": "^7.12.13",
- "history": "^4.9.0",
- "hoist-non-react-statics": "^3.1.0",
- "loose-envify": "^1.3.1",
- "path-to-regexp": "^1.7.0",
- "prop-types": "^15.6.2",
- "react-is": "^16.6.0",
- "tiny-invariant": "^1.0.2",
- "tiny-warning": "^1.0.0"
+ "@remix-run/router": "1.23.0"
+ },
+ "engines": {
+ "node": ">=14.0.0"
},
"peerDependencies": {
- "react": ">=15"
+ "react": ">=16.8"
}
},
"node_modules/react-router-dom": {
- "version": "5.3.4",
- "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-5.3.4.tgz",
- "integrity": "sha512-m4EqFMHv/Ih4kpcBCONHbkT68KoAeHN4p3lAGoNryfHi0dMy0kCzEZakiKRsvg5wHZ/JLrLW8o8KomWiz/qbYQ==",
+ "version": "6.30.0",
+ "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.30.0.tgz",
+ "integrity": "sha512-x30B78HV5tFk8ex0ITwzC9TTZMua4jGyA9IUlH1JLQYQTFyxr/ZxwOJq7evg1JX1qGVUcvhsmQSKdPncQrjTgA==",
"dev": true,
"dependencies": {
- "@babel/runtime": "^7.12.13",
- "history": "^4.9.0",
- "loose-envify": "^1.3.1",
- "prop-types": "^15.6.2",
- "react-router": "5.3.4",
- "tiny-invariant": "^1.0.2",
- "tiny-warning": "^1.0.0"
+ "@remix-run/router": "1.23.0",
+ "react-router": "6.30.0"
+ },
+ "engines": {
+ "node": ">=14.0.0"
},
"peerDependencies": {
- "react": ">=15"
+ "react": ">=16.8",
+ "react-dom": ">=16.8"
}
},
- "node_modules/regenerator-runtime": {
- "version": "0.14.0",
- "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.14.0.tgz",
- "integrity": "sha512-srw17NI0TUWHuGa5CFGGmhfNIeja30WMBfbslPNhf6JrqQlLN5gcrvig1oqPxiVaXb0oW0XRKtH6Nngs5lKCIA==",
- "dev": true
- },
"node_modules/regexp.prototype.flags": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.1.tgz",
@@ -3354,12 +3276,6 @@
"node": ">=4"
}
},
- "node_modules/resolve-pathname": {
- "version": "3.0.0",
- "resolved": "https://registry.npmjs.org/resolve-pathname/-/resolve-pathname-3.0.0.tgz",
- "integrity": "sha512-C7rARubxI8bXFNB/hqcp/4iUeIXJhJZvFPFPiSPRnhU5UPxzMFIl+2E6yY6c4k9giDJAhtV+enfA+G89N6Csng==",
- "dev": true
- },
"node_modules/reusify": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/reusify/-/reusify-1.0.4.tgz",
@@ -3754,18 +3670,6 @@
"integrity": "sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==",
"dev": true
},
- "node_modules/tiny-invariant": {
- "version": "1.3.1",
- "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.1.tgz",
- "integrity": "sha512-AD5ih2NlSssTCwsMznbvwMZpJ1cbhkGd2uueNxzv2jDlEeZdU04JQfRnggJQ8DrcVBGjAsCKwFBbDlVNtEMlzw==",
- "dev": true
- },
- "node_modules/tiny-warning": {
- "version": "1.0.3",
- "resolved": "https://registry.npmjs.org/tiny-warning/-/tiny-warning-1.0.3.tgz",
- "integrity": "sha512-lBN9zLN/oAf68o3zNXYrdCt1kP8WsiGW8Oo2ka41b2IM5JL/S1CTyX1rW0mb/zSuJun0ZUrDxx4sqvYS2FWzPA==",
- "dev": true
- },
"node_modules/to-regex-range": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
@@ -3948,12 +3852,6 @@
"integrity": "sha512-ocyWc3bAHBB/guyqJQVI5o4BZkPhznPYUG2ea80Gond/BgNWpap8TOmLSeeQG7bnh2KMISxskdADG59j7zruhw==",
"dev": true
},
- "node_modules/value-equal": {
- "version": "1.0.1",
- "resolved": "https://registry.npmjs.org/value-equal/-/value-equal-1.0.1.tgz",
- "integrity": "sha512-NOJ6JZCAWr0zlxZt+xqCHNTEKOsrks2HQd4MqhP1qy4z1SkbEP467eNx6TgDKXMvUOb+OENfJCZwM+16n7fRfw==",
- "dev": true
- },
"node_modules/which": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
@@ -4109,13 +4007,10 @@
}
},
"@babel/runtime": {
- "version": "7.23.2",
- "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.23.2.tgz",
- "integrity": "sha512-mM8eg4yl5D6i3lu2QKPuPH4FArvJ8KhTofbE7jwMUv9KX5mBvwPAqnV3MlyBNqdp9RyRKP6Yck8TrfYrPvX3bg==",
- "dev": true,
- "requires": {
- "regenerator-runtime": "^0.14.0"
- }
+ "version": "7.28.4",
+ "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.4.tgz",
+ "integrity": "sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ==",
+ "dev": true
},
"@eslint-community/eslint-utils": {
"version": "4.4.0",
@@ -4316,6 +4211,12 @@
"fastq": "^1.6.0"
}
},
+ "@remix-run/router": {
+ "version": "1.23.0",
+ "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.0.tgz",
+ "integrity": "sha512-O3rHJzAQKamUz1fvE0Qaw0xSFqsA/yafi2iqeE0pvdFtCO1viYx8QL6f3Ln/aCCTLxs68SLf0KPM9eSeM8yBnA==",
+ "dev": true
+ },
"@rollup/plugin-typescript": {
"version": "11.1.5",
"resolved": "https://registry.npmjs.org/@rollup/plugin-typescript/-/plugin-typescript-11.1.5.tgz",
@@ -4492,12 +4393,6 @@
"integrity": "sha512-2JwWnHK9H+wUZNorf2Zr6ves96WHoWDJIftkcxPKsS7Djta6Zu519LarhRNljPXkpsZR2ZMwNCPeW7omW07BJw==",
"dev": true
},
- "@types/history": {
- "version": "4.7.11",
- "resolved": "https://registry.npmjs.org/@types/history/-/history-4.7.11.tgz",
- "integrity": "sha512-qjDJRrmvBMiTx+jyLxvLfJU7UznFuokDv4f3WRuriHKERccVpFU+8XMQUAbDzoiJCsmexxRExQeMwwCdamSKDA==",
- "dev": true
- },
"@types/json-schema": {
"version": "7.0.14",
"resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.14.tgz",
@@ -4542,27 +4437,6 @@
"@types/react": "^17"
}
},
- "@types/react-router": {
- "version": "5.1.20",
- "resolved": "https://registry.npmjs.org/@types/react-router/-/react-router-5.1.20.tgz",
- "integrity": "sha512-jGjmu/ZqS7FjSH6owMcD5qpq19+1RS9DeVRqfl1FeBMxTDQAGwlMWOcs52NDoXaNKyG3d1cYQFMs9rCrb88o9Q==",
- "dev": true,
- "requires": {
- "@types/history": "^4.7.11",
- "@types/react": "*"
- }
- },
- "@types/react-router-dom": {
- "version": "5.3.3",
- "resolved": "https://registry.npmjs.org/@types/react-router-dom/-/react-router-dom-5.3.3.tgz",
- "integrity": "sha512-kpqnYK4wcdm5UaWI3fLcELopqLrHgLqNsdpHauzlQktfkHL3npOSwtj1Uz9oKBAzs7lFtVkV8j83voAz2D8fhw==",
- "dev": true,
- "requires": {
- "@types/history": "^4.7.11",
- "@types/react": "*",
- "@types/react-router": "*"
- }
- },
"@types/scheduler": {
"version": "0.16.8",
"resolved": "https://registry.npmjs.org/@types/scheduler/-/scheduler-0.16.8.tgz",
@@ -5690,26 +5564,12 @@
}
},
"history": {
- "version": "4.10.1",
- "resolved": "https://registry.npmjs.org/history/-/history-4.10.1.tgz",
- "integrity": "sha512-36nwAD620w12kuzPAsyINPWJqlNbij+hpK1k9XRloDtym8mxzGYl2c17LnV6IAGB2Dmg4tEa7G7DlawS0+qjew==",
- "dev": true,
- "requires": {
- "@babel/runtime": "^7.1.2",
- "loose-envify": "^1.2.0",
- "resolve-pathname": "^3.0.0",
- "tiny-invariant": "^1.0.2",
- "tiny-warning": "^1.0.0",
- "value-equal": "^1.0.1"
- }
- },
- "hoist-non-react-statics": {
- "version": "3.3.2",
- "resolved": "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz",
- "integrity": "sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==",
+ "version": "5.3.0",
+ "resolved": "https://registry.npmjs.org/history/-/history-5.3.0.tgz",
+ "integrity": "sha512-ZqaKwjjrAYUYfLG+htGaIIZ4nioX2L70ZUMIFysS3xvBsSG4x/n1V6TXV3N8ZYNuFGlDirFg32T7B6WOUPDYcQ==",
"dev": true,
"requires": {
- "react-is": "^16.7.0"
+ "@babel/runtime": "^7.7.6"
}
},
"ignore": {
@@ -5920,12 +5780,6 @@
"call-bind": "^1.0.2"
}
},
- "isarray": {
- "version": "0.0.1",
- "resolved": "https://registry.npmjs.org/isarray/-/isarray-0.0.1.tgz",
- "integrity": "sha512-D2S+3GLxWH+uhrNEcoh/fnmYeP8E8/zHl644d/jdA0g2uyXvy3sb0qxotE+ne0LtccHknQzWwZEzhak7oJ0COQ==",
- "dev": true
- },
"isexe": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
@@ -6184,15 +6038,6 @@
"integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==",
"dev": true
},
- "path-to-regexp": {
- "version": "1.8.0",
- "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-1.8.0.tgz",
- "integrity": "sha512-n43JRhlUKUAlibEJhPeir1ncUID16QnEjNpwzNdO3Lm4ywrBpBZ5oLD0I6br9evr1Y9JTqwRtAh7JLoOzAQdVA==",
- "dev": true,
- "requires": {
- "isarray": "0.0.1"
- }
- },
"path-type": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz",
@@ -6223,17 +6068,6 @@
"integrity": "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==",
"dev": true
},
- "prop-types": {
- "version": "15.8.1",
- "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
- "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==",
- "dev": true,
- "requires": {
- "loose-envify": "^1.4.0",
- "object-assign": "^4.1.1",
- "react-is": "^16.13.1"
- }
- },
"punycode": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
@@ -6265,50 +6099,25 @@
"scheduler": "^0.20.2"
}
},
- "react-is": {
- "version": "16.13.1",
- "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
- "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
- "dev": true
- },
"react-router": {
- "version": "5.3.4",
- "resolved": "https://registry.npmjs.org/react-router/-/react-router-5.3.4.tgz",
- "integrity": "sha512-Ys9K+ppnJah3QuaRiLxk+jDWOR1MekYQrlytiXxC1RyfbdsZkS5pvKAzCCr031xHixZwpnsYNT5xysdFHQaYsA==",
+ "version": "6.30.0",
+ "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.30.0.tgz",
+ "integrity": "sha512-D3X8FyH9nBcTSHGdEKurK7r8OYE1kKFn3d/CF+CoxbSHkxU7o37+Uh7eAHRXr6k2tSExXYO++07PeXJtA/dEhQ==",
"dev": true,
"requires": {
- "@babel/runtime": "^7.12.13",
- "history": "^4.9.0",
- "hoist-non-react-statics": "^3.1.0",
- "loose-envify": "^1.3.1",
- "path-to-regexp": "^1.7.0",
- "prop-types": "^15.6.2",
- "react-is": "^16.6.0",
- "tiny-invariant": "^1.0.2",
- "tiny-warning": "^1.0.0"
+ "@remix-run/router": "1.23.0"
}
},
"react-router-dom": {
- "version": "5.3.4",
- "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-5.3.4.tgz",
- "integrity": "sha512-m4EqFMHv/Ih4kpcBCONHbkT68KoAeHN4p3lAGoNryfHi0dMy0kCzEZakiKRsvg5wHZ/JLrLW8o8KomWiz/qbYQ==",
+ "version": "6.30.0",
+ "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.30.0.tgz",
+ "integrity": "sha512-x30B78HV5tFk8ex0ITwzC9TTZMua4jGyA9IUlH1JLQYQTFyxr/ZxwOJq7evg1JX1qGVUcvhsmQSKdPncQrjTgA==",
"dev": true,
"requires": {
- "@babel/runtime": "^7.12.13",
- "history": "^4.9.0",
- "loose-envify": "^1.3.1",
- "prop-types": "^15.6.2",
- "react-router": "5.3.4",
- "tiny-invariant": "^1.0.2",
- "tiny-warning": "^1.0.0"
+ "@remix-run/router": "1.23.0",
+ "react-router": "6.30.0"
}
},
- "regenerator-runtime": {
- "version": "0.14.0",
- "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.14.0.tgz",
- "integrity": "sha512-srw17NI0TUWHuGa5CFGGmhfNIeja30WMBfbslPNhf6JrqQlLN5gcrvig1oqPxiVaXb0oW0XRKtH6Nngs5lKCIA==",
- "dev": true
- },
"regexp.prototype.flags": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.1.tgz",
@@ -6349,12 +6158,6 @@
"integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==",
"dev": true
},
- "resolve-pathname": {
- "version": "3.0.0",
- "resolved": "https://registry.npmjs.org/resolve-pathname/-/resolve-pathname-3.0.0.tgz",
- "integrity": "sha512-C7rARubxI8bXFNB/hqcp/4iUeIXJhJZvFPFPiSPRnhU5UPxzMFIl+2E6yY6c4k9giDJAhtV+enfA+G89N6Csng==",
- "dev": true
- },
"reusify": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/reusify/-/reusify-1.0.4.tgz",
@@ -6640,18 +6443,6 @@
"integrity": "sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==",
"dev": true
},
- "tiny-invariant": {
- "version": "1.3.1",
- "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.1.tgz",
- "integrity": "sha512-AD5ih2NlSssTCwsMznbvwMZpJ1cbhkGd2uueNxzv2jDlEeZdU04JQfRnggJQ8DrcVBGjAsCKwFBbDlVNtEMlzw==",
- "dev": true
- },
- "tiny-warning": {
- "version": "1.0.3",
- "resolved": "https://registry.npmjs.org/tiny-warning/-/tiny-warning-1.0.3.tgz",
- "integrity": "sha512-lBN9zLN/oAf68o3zNXYrdCt1kP8WsiGW8Oo2ka41b2IM5JL/S1CTyX1rW0mb/zSuJun0ZUrDxx4sqvYS2FWzPA==",
- "dev": true
- },
"to-regex-range": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
@@ -6790,12 +6581,6 @@
"integrity": "sha512-ocyWc3bAHBB/guyqJQVI5o4BZkPhznPYUG2ea80Gond/BgNWpap8TOmLSeeQG7bnh2KMISxskdADG59j7zruhw==",
"dev": true
},
- "value-equal": {
- "version": "1.0.1",
- "resolved": "https://registry.npmjs.org/value-equal/-/value-equal-1.0.1.tgz",
- "integrity": "sha512-NOJ6JZCAWr0zlxZt+xqCHNTEKOsrks2HQd4MqhP1qy4z1SkbEP467eNx6TgDKXMvUOb+OENfJCZwM+16n7fRfw==",
- "dev": true
- },
"which": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
diff --git a/packages/react-router/package.json b/packages/react-router/package.json
index 6caca04b3dc..0ce6800092f 100644
--- a/packages/react-router/package.json
+++ b/packages/react-router/package.json
@@ -42,8 +42,8 @@
"peerDependencies": {
"react": ">=16.8.6",
"react-dom": ">=16.8.6",
- "react-router": "^5.0.1",
- "react-router-dom": "^5.0.1"
+ "react-router": ">=6.0.0",
+ "react-router-dom": ">=6.0.0"
},
"devDependencies": {
"@ionic/eslint-config": "^0.3.0",
@@ -52,16 +52,15 @@
"@types/node": "^14.0.14",
"@types/react": "^17.0.79",
"@types/react-dom": "^17.0.25",
- "@types/react-router": "^5.0.3",
- "@types/react-router-dom": "^5.1.5",
+ "history": "^5.3.0",
"@typescript-eslint/eslint-plugin": "^5.48.2",
"@typescript-eslint/parser": "^5.48.2",
"eslint": "^7.32.0",
"prettier": "^2.8.3",
"react": "^17.0.2",
"react-dom": "^17.0.2",
- "react-router": "^5.0.1",
- "react-router-dom": "^5.0.1",
+ "react-router": "^6.30.0",
+ "react-router-dom": "^6.30.0",
"rimraf": "^3.0.2",
"rollup": "^4.2.0",
"typescript": "^4.0.5"
diff --git a/packages/react-router/scripts/sync.sh b/packages/react-router/scripts/sync.sh
index b8f03de4cf2..c69a98efc99 100644
--- a/packages/react-router/scripts/sync.sh
+++ b/packages/react-router/scripts/sync.sh
@@ -2,14 +2,14 @@
set -e
-# Copy ionic react dist
-rm -rf node_modules/@ionic/react/dist node_modules/@ionic/react/css
-cp -a ../react/dist node_modules/@ionic/react/dist
-cp -a ../react/css node_modules/@ionic/react/css
-cp -a ../react/package.json node_modules/@ionic/react/package.json
-
-# Copy core dist
-rm -rf node_modules/@ionic/core/dist node_modules/@ionic/core/components
-cp -a ../../core/dist node_modules/@ionic/core/dist
-cp -a ../../core/components node_modules/@ionic/core/components
-cp -a ../../core/package.json node_modules/@ionic/core/package.json
+# Delete old packages
+rm -f *.tgz
+
+# Pack @ionic/react
+npm pack ../react
+
+# Pack @ionic/core
+npm pack ../../core
+
+# Install Dependencies
+npm install *.tgz --no-save
diff --git a/packages/react-router/scripts/test_runner.sh b/packages/react-router/scripts/test_runner.sh
new file mode 100755
index 00000000000..9e616448a88
--- /dev/null
+++ b/packages/react-router/scripts/test_runner.sh
@@ -0,0 +1,68 @@
+#!/bin/bash
+set -x
+
+# Change to script's directory
+SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+cd "$SCRIPT_DIR"
+
+# Inside core
+echo "Building core..."
+cd ../../../core
+npm run build
+
+# Inside packages/react
+echo "Building packages/react..."
+cd ../packages/react
+npm ci
+npm run sync
+npm run build
+
+# Inside packages/react-router
+echo "Building packages/react-router..."
+cd ../react-router
+npm ci
+npm run sync
+npm run build
+
+# Inside packages/react-router/test
+echo "Building test app..."
+cd ./test
+rm -rf build/reactrouter6 || true
+sh ./build.sh reactrouter6
+cd build/reactrouter6
+echo "Installing dependencies..."
+npm install --legacy-peer-deps > npm_install.log 2>&1
+npm run sync
+
+echo "Cleaning up port 3000..."
+lsof -ti:3000 | xargs kill -9 || true
+
+echo "Starting server..."
+# Start server in background and save PID
+npm start > server.log 2>&1 &
+SERVER_PID=$!
+
+# Ensure server is killed on script exit
+trap "kill $SERVER_PID" EXIT
+
+echo "Waiting for server to start (30s)..."
+sleep 30
+
+echo "Checking server status..."
+SERVER_RESPONSE=$(curl -s -v http://localhost:3000 2>&1)
+if echo "$SERVER_RESPONSE" | grep -q "Child compilation failed"; then
+ echo "Server started but has compilation errors. Exiting."
+ echo "$SERVER_RESPONSE"
+ exit 1
+fi
+
+if ! echo "$SERVER_RESPONSE" | grep -q "200 OK"; then
+ echo "Server did not return 200 OK. Exiting."
+ echo "$SERVER_RESPONSE"
+ exit 1
+fi
+echo "Server is healthy."
+
+echo "Running Cypress tests..."
+npm run cypress
+
diff --git a/packages/react-router/src/ReactRouter/IonReactHashRouter.tsx b/packages/react-router/src/ReactRouter/IonReactHashRouter.tsx
index 7908a0f25ec..a216ee60fb6 100644
--- a/packages/react-router/src/ReactRouter/IonReactHashRouter.tsx
+++ b/packages/react-router/src/ReactRouter/IonReactHashRouter.tsx
@@ -1,53 +1,55 @@
-import type { Action as HistoryAction, History, Location as HistoryLocation } from 'history';
-import { createHashHistory as createHistory } from 'history';
-import React from 'react';
-import type { BrowserRouterProps } from 'react-router-dom';
-import { Router } from 'react-router-dom';
+/**
+ * `IonReactHashRouter` provides a way to use hash-based routing in Ionic
+ * React applications.
+ */
+
+import type { Action as HistoryAction, Location as HistoryLocation } from 'history';
+import type { PropsWithChildren } from 'react';
+import React, { useEffect, useRef } from 'react';
+import type { HashRouterProps } from 'react-router-dom';
+import { HashRouter, useLocation, useNavigationType } from 'react-router-dom';
import { IonRouter } from './IonRouter';
-interface IonReactHashRouterProps extends BrowserRouterProps {
- history?: History;
-}
+const RouterContent = ({ children }: PropsWithChildren<{}>) => {
+ const location = useLocation();
+ const navigationType = useNavigationType();
-export class IonReactHashRouter extends React.Component {
- history: History;
- historyListenHandler?: (location: HistoryLocation, action: HistoryAction) => void;
+ const historyListenHandler = useRef<(location: HistoryLocation, action: HistoryAction) => void>();
- constructor(props: IonReactHashRouterProps) {
- super(props);
- const { history, ...rest } = props;
- this.history = history || createHistory(rest);
- this.history.listen(this.handleHistoryChange.bind(this));
- this.registerHistoryListener = this.registerHistoryListener.bind(this);
- }
+ const registerHistoryListener = (cb: (location: HistoryLocation, action: HistoryAction) => void) => {
+ historyListenHandler.current = cb;
+ };
/**
- * history@4.x passes separate location and action
- * params. history@5.x passes location and action
- * together as a single object.
- * TODO: If support for React Router <=5 is dropped
- * this logic is no longer needed. We can just assume
- * a single object with both location and action.
+ * Processes navigation changes within the application.
+ *
+ * Its purpose is to relay the current `location` and the associated
+ * `action` ('PUSH', 'POP', or 'REPLACE') to any registered listeners,
+ * primarily for `IonRouter` to manage Ionic-specific UI updates and
+ * navigation stack behavior.
+ *
+ * @param location The current browser history location object.
+ * @param action The type of navigation action ('PUSH', 'POP', or
+ * 'REPLACE').
*/
- handleHistoryChange(location: HistoryLocation, action: HistoryAction) {
- const locationValue = (location as any).location || location;
- const actionValue = (location as any).action || action;
- if (this.historyListenHandler) {
- this.historyListenHandler(locationValue, actionValue);
+ const handleHistoryChange = (location: HistoryLocation, action: HistoryAction) => {
+ if (historyListenHandler.current) {
+ historyListenHandler.current(location, action);
}
- }
-
- registerHistoryListener(cb: (location: HistoryLocation, action: HistoryAction) => void) {
- this.historyListenHandler = cb;
- }
-
- render() {
- const { children, ...props } = this.props;
- return (
-
- {children}
-
- );
- }
-}
+ };
+
+ useEffect(() => {
+ handleHistoryChange(location, navigationType);
+ }, [location, navigationType]);
+
+ return {children};
+};
+
+export const IonReactHashRouter = ({ children, ...routerProps }: PropsWithChildren) => {
+ return (
+
+ {children}
+
+ );
+};
diff --git a/packages/react-router/src/ReactRouter/IonReactMemoryRouter.tsx b/packages/react-router/src/ReactRouter/IonReactMemoryRouter.tsx
index c3bf8d74bbc..8f2ff229890 100644
--- a/packages/react-router/src/ReactRouter/IonReactMemoryRouter.tsx
+++ b/packages/react-router/src/ReactRouter/IonReactMemoryRouter.tsx
@@ -1,51 +1,56 @@
-import type { Action as HistoryAction, Location as HistoryLocation, MemoryHistory } from 'history';
-import React from 'react';
+/**
+ * `IonReactMemoryRouter` provides a way to use `react-router` in
+ * environments where a traditional browser history (like `BrowserRouter`)
+ * isn't available or desirable.
+ */
+
+import type { Action as HistoryAction, Location as HistoryLocation } from 'history';
+import type { PropsWithChildren } from 'react';
+import React, { useEffect, useRef } from 'react';
import type { MemoryRouterProps } from 'react-router';
-import { Router } from 'react-router';
+import { MemoryRouter, useLocation, useNavigationType } from 'react-router';
import { IonRouter } from './IonRouter';
-interface IonReactMemoryRouterProps extends MemoryRouterProps {
- history: MemoryHistory;
-}
+const RouterContent = ({ children }: PropsWithChildren<{}>) => {
+ const location = useLocation();
+ const navigationType = useNavigationType();
-export class IonReactMemoryRouter extends React.Component {
- history: MemoryHistory;
- historyListenHandler?: (location: HistoryLocation, action: HistoryAction) => void;
+ const historyListenHandler = useRef<(location: HistoryLocation, action: HistoryAction) => void>();
- constructor(props: IonReactMemoryRouterProps) {
- super(props);
- this.history = props.history;
- this.history.listen(this.handleHistoryChange.bind(this));
- this.registerHistoryListener = this.registerHistoryListener.bind(this);
- }
+ const registerHistoryListener = (cb: (location: HistoryLocation, action: HistoryAction) => void) => {
+ historyListenHandler.current = cb;
+ };
/**
- * history@4.x passes separate location and action
- * params. history@5.x passes location and action
- * together as a single object.
- * TODO: If support for React Router <=5 is dropped
- * this logic is no longer needed. We can just assume
- * a single object with both location and action.
+ * Processes navigation changes within the application.
+ *
+ * Its purpose is to relay the current `location` and the associated
+ * `action` ('PUSH', 'POP', or 'REPLACE') to any registered listeners,
+ * primarily for `IonRouter` to manage Ionic-specific UI updates and
+ * navigation stack behavior.
+ *
+ * @param location The current browser history location object.
+ * @param action The type of navigation action ('PUSH', 'POP', or
+ * 'REPLACE').
*/
- handleHistoryChange(location: HistoryLocation, action: HistoryAction) {
- const locationValue = (location as any).location || location;
- const actionValue = (location as any).action || action;
- if (this.historyListenHandler) {
- this.historyListenHandler(locationValue, actionValue);
+ const handleHistoryChange = (location: HistoryLocation, action: HistoryAction) => {
+ if (historyListenHandler.current) {
+ historyListenHandler.current(location, action);
}
- }
-
- registerHistoryListener(cb: (location: HistoryLocation, action: HistoryAction) => void) {
- this.historyListenHandler = cb;
- }
-
- render() {
- const { children, ...props } = this.props;
- return (
-
- {children}
-
- );
- }
-}
+ };
+
+ useEffect(() => {
+ handleHistoryChange(location, navigationType);
+ }, [location, navigationType]);
+
+ return {children};
+};
+
+export const IonReactMemoryRouter = ({ children, ...routerProps }: PropsWithChildren) => {
+ return (
+
+ {children}
+
+ );
+};
diff --git a/packages/react-router/src/ReactRouter/IonReactRouter.tsx b/packages/react-router/src/ReactRouter/IonReactRouter.tsx
index 0bde5599165..f34a5b5d118 100644
--- a/packages/react-router/src/ReactRouter/IonReactRouter.tsx
+++ b/packages/react-router/src/ReactRouter/IonReactRouter.tsx
@@ -1,53 +1,65 @@
-import type { Action as HistoryAction, History, Location as HistoryLocation } from 'history';
-import { createBrowserHistory as createHistory } from 'history';
-import React from 'react';
+/**
+ * `IonReactRouter` facilitates the integration of Ionic's specific
+ * navigation and UI management with the standard React Router mechanisms,
+ * allowing an inner Ionic-specific router (`IonRouter`) to react to
+ * navigation events.
+ */
+
+import type { Action as HistoryAction, Location as HistoryLocation } from 'history';
+import type { PropsWithChildren } from 'react';
+import React, { useEffect, useRef, useCallback } from 'react';
import type { BrowserRouterProps } from 'react-router-dom';
-import { Router } from 'react-router-dom';
+import { BrowserRouter, useLocation, useNavigationType } from 'react-router-dom';
import { IonRouter } from './IonRouter';
-interface IonReactRouterProps extends BrowserRouterProps {
- history?: History;
-}
+/**
+ * This component acts as a bridge to ensure React Router hooks like
+ * `useLocation` and `useNavigationType` are called within the valid
+ * context of a ``.
+ *
+ * It was split from `IonReactRouter` because these hooks must be
+ * descendants of a `` component, which `BrowserRouter` provides.
+ */
+const RouterContent = ({ children }: PropsWithChildren<{}>) => {
+ const location = useLocation();
+ const navigationType = useNavigationType();
-export class IonReactRouter extends React.Component {
- historyListenHandler?: (location: HistoryLocation, action: HistoryAction) => void;
- history: History;
+ const historyListenHandler = useRef<(location: HistoryLocation, action: HistoryAction) => void>();
- constructor(props: IonReactRouterProps) {
- super(props);
- const { history, ...rest } = props;
- this.history = history || createHistory(rest);
- this.history.listen(this.handleHistoryChange.bind(this));
- this.registerHistoryListener = this.registerHistoryListener.bind(this);
- }
+ const registerHistoryListener = useCallback((cb: (location: HistoryLocation, action: HistoryAction) => void) => {
+ historyListenHandler.current = cb;
+ }, []);
/**
- * history@4.x passes separate location and action
- * params. history@5.x passes location and action
- * together as a single object.
- * TODO: If support for React Router <=5 is dropped
- * this logic is no longer needed. We can just assume
- * a single object with both location and action.
+ * Processes navigation changes within the application.
+ *
+ * Its purpose is to relay the current `location` and the associated
+ * `action` ('PUSH', 'POP', or 'REPLACE') to any registered listeners,
+ * primarily for `IonRouter` to manage Ionic-specific UI updates and
+ * navigation stack behavior.
+ *
+ * @param loc The current browser history location object.
+ * @param act The type of navigation action ('PUSH', 'POP', or
+ * 'REPLACE').
*/
- handleHistoryChange(location: HistoryLocation, action: HistoryAction) {
- const locationValue = (location as any).location || location;
- const actionValue = (location as any).action || action;
- if (this.historyListenHandler) {
- this.historyListenHandler(locationValue, actionValue);
+ const handleHistoryChange = useCallback((loc: HistoryLocation, act: HistoryAction) => {
+ if (historyListenHandler.current) {
+ historyListenHandler.current(loc, act);
}
- }
-
- registerHistoryListener(cb: (location: HistoryLocation, action: HistoryAction) => void) {
- this.historyListenHandler = cb;
- }
-
- render() {
- const { children, ...props } = this.props;
- return (
-
- {children}
-
- );
- }
-}
+ }, []);
+
+ useEffect(() => {
+ handleHistoryChange(location, navigationType);
+ }, [location, navigationType, handleHistoryChange]);
+
+ return {children};
+};
+
+export const IonReactRouter = ({ children, ...browserRouterProps }: PropsWithChildren) => {
+ return (
+
+ {children}
+
+ );
+};
diff --git a/packages/react-router/src/ReactRouter/IonRouteInner.tsx b/packages/react-router/src/ReactRouter/IonRouteInner.tsx
index ddeb0e74d31..4d3a440df85 100644
--- a/packages/react-router/src/ReactRouter/IonRouteInner.tsx
+++ b/packages/react-router/src/ReactRouter/IonRouteInner.tsx
@@ -1,30 +1,7 @@
import type { IonRouteProps } from '@ionic/react';
import React from 'react';
-import { Route } from 'react-router';
+import { Route } from 'react-router-dom';
-export class IonRouteInner extends React.PureComponent {
- render() {
- return (
-
- );
- }
-}
+export const IonRouteInner = ({ path, element }: IonRouteProps) => {
+ return ;
+};
diff --git a/packages/react-router/src/ReactRouter/IonRouter.tsx b/packages/react-router/src/ReactRouter/IonRouter.tsx
index fcadd6561a6..69549956022 100644
--- a/packages/react-router/src/ReactRouter/IonRouter.tsx
+++ b/packages/react-router/src/ReactRouter/IonRouter.tsx
@@ -1,182 +1,293 @@
+/**
+ * `IonRouter` is responsible for managing the application's navigation
+ * state, tracking the history of visited routes, and coordinating
+ * transitions between different views. It intercepts route changes from
+ * React Router and translates them into actions that Ionic can understand
+ * and animate.
+ */
+
import type {
AnimationBuilder,
RouteAction,
RouteInfo,
RouteManagerContextState,
RouterDirection,
- ViewItem,
+ RouterOptions,
} from '@ionic/react';
import { LocationHistory, NavManager, RouteManagerContext, generateId, getConfig } from '@ionic/react';
-import type { Action as HistoryAction, Location as HistoryLocation } from 'history';
-import React from 'react';
-import type { RouteComponentProps } from 'react-router-dom';
-import { withRouter } from 'react-router-dom';
+import type { Action as HistoryAction, Location } from 'history';
+import type { PropsWithChildren } from 'react';
+import React, { useEffect, useRef, useState } from 'react';
+import { useLocation, useNavigate } from 'react-router-dom';
import { IonRouteInner } from './IonRouteInner';
import { ReactRouterViewStack } from './ReactRouterViewStack';
import StackManager from './StackManager';
+// Use Location directly - state is typed as `unknown` in history v5
+type HistoryLocation = Location;
+
export interface LocationState {
direction?: RouterDirection;
- routerOptions?: { as?: string; unmount?: boolean };
+ routerOptions?: RouterOptions;
}
-interface IonRouteProps extends RouteComponentProps<{}, {}, LocationState> {
- registerHistoryListener: (cb: (location: HistoryLocation, action: HistoryAction) => void) => void;
+interface IonRouterProps {
+ registerHistoryListener: (cb: (location: HistoryLocation, action: HistoryAction) => void) => void;
}
-interface IonRouteState {
- routeInfo: RouteInfo;
-}
-
-class IonRouterInner extends React.PureComponent {
- currentTab?: string;
- exitViewFromOtherOutletHandlers: ((pathname: string) => ViewItem | undefined)[] = [];
- incomingRouteParams?: Partial;
- locationHistory = new LocationHistory();
- viewStack = new ReactRouterViewStack();
- routeMangerContextState: RouteManagerContextState = {
- canGoBack: () => this.locationHistory.canGoBack(),
- clearOutlet: this.viewStack.clear,
- findViewItemByPathname: this.viewStack.findViewItemByPathname,
- getChildrenToRender: this.viewStack.getChildrenToRender,
- goBack: () => this.handleNavigateBack(),
- createViewItem: this.viewStack.createViewItem,
- findViewItemByRouteInfo: this.viewStack.findViewItemByRouteInfo,
- findLeavingViewItemByRouteInfo: this.viewStack.findLeavingViewItemByRouteInfo,
- addViewItem: this.viewStack.add,
- unMountViewItem: this.viewStack.remove,
- };
+type RouteParams = Record;
+type SafeRouteParams = Record;
- constructor(props: IonRouteProps) {
- super(props);
+const filterUndefinedParams = (params: RouteParams): SafeRouteParams => {
+ const result: SafeRouteParams = {};
+ for (const key of Object.keys(params)) {
+ const value = params[key];
+ if (value !== undefined) {
+ result[key] = value;
+ }
+ }
+ return result;
+};
- const routeInfo = {
- id: generateId('routeInfo'),
- pathname: this.props.location.pathname,
- search: this.props.location.search,
- };
+const areParamsEqual = (a?: RouteParams, b?: RouteParams) => {
+ const paramsA = a || {};
+ const paramsB = b || {};
+ const keysA = Object.keys(paramsA);
+ const keysB = Object.keys(paramsB);
- this.locationHistory.add(routeInfo);
- this.handleChangeTab = this.handleChangeTab.bind(this);
- this.handleResetTab = this.handleResetTab.bind(this);
- this.handleNativeBack = this.handleNativeBack.bind(this);
- this.handleNavigate = this.handleNavigate.bind(this);
- this.handleNavigateBack = this.handleNavigateBack.bind(this);
- this.props.registerHistoryListener(this.handleHistoryChange.bind(this));
- this.handleSetCurrentTab = this.handleSetCurrentTab.bind(this);
-
- this.state = {
- routeInfo,
- };
+ if (keysA.length !== keysB.length) {
+ return false;
}
- handleChangeTab(tab: string, path?: string, routeOptions?: any) {
- if (!path) {
+ return keysA.every((key) => {
+ const valueA = paramsA[key];
+ const valueB = paramsB[key];
+ if (Array.isArray(valueA) && Array.isArray(valueB)) {
+ if (valueA.length !== valueB.length) {
+ return false;
+ }
+ return valueA.every((entry, idx) => entry === valueB[idx]);
+ }
+ return valueA === valueB;
+ });
+};
+
+export const IonRouter = ({ children, registerHistoryListener }: PropsWithChildren) => {
+ const location = useLocation();
+ const navigate = useNavigate();
+
+ const didMountRef = useRef(false);
+ const locationHistory = useRef(new LocationHistory());
+ const currentTab = useRef(undefined);
+ const viewStack = useRef(new ReactRouterViewStack());
+ const incomingRouteParams = useRef | null>(null);
+
+ const [routeInfo, setRouteInfo] = useState({
+ id: generateId('routeInfo'),
+ pathname: location.pathname,
+ search: location.search,
+ params: {},
+ });
+
+ useEffect(() => {
+ if (didMountRef.current) {
return;
}
- const routeInfo = this.locationHistory.getCurrentRouteInfoForTab(tab);
- const [pathname, search] = path.split('?');
- if (routeInfo) {
- this.incomingRouteParams = { ...routeInfo, routeAction: 'push', routeDirection: 'none' };
- if (routeInfo.pathname === pathname) {
- this.incomingRouteParams.routeOptions = routeOptions;
- this.props.history.push(routeInfo.pathname + (routeInfo.search || ''));
- } else {
- this.incomingRouteParams.pathname = pathname;
- this.incomingRouteParams.search = search ? '?' + search : undefined;
- this.incomingRouteParams.routeOptions = routeOptions;
- this.props.history.push(pathname + (search ? '?' + search : ''));
+ // Seed the history stack with the initial location and begin listening
+ // for future navigations once React has committed the mount. This avoids
+ // duplicate entries when React StrictMode runs an extra render pre-commit.
+ locationHistory.current.add(routeInfo);
+ registerHistoryListener(handleHistoryChange);
+
+ didMountRef.current = true;
+ }, []);
+
+ useEffect(() => {
+ const activeView = viewStack.current.findViewItemByRouteInfo(routeInfo, undefined, true);
+ const matchedParams = activeView?.routeData.match?.params as RouteParams | undefined;
+
+ if (matchedParams) {
+ const paramsCopy = filterUndefinedParams({ ...matchedParams });
+ if (areParamsEqual(routeInfo.params as RouteParams | undefined, paramsCopy)) {
+ return;
}
- } else {
- this.handleNavigate(pathname, 'push', 'none', undefined, routeOptions, tab);
+
+ const updatedRouteInfo: RouteInfo = {
+ ...routeInfo,
+ params: paramsCopy,
+ };
+ locationHistory.current.update(updatedRouteInfo);
+ setRouteInfo(updatedRouteInfo);
}
- }
+ }, [routeInfo]);
- handleHistoryChange(location: HistoryLocation, action: HistoryAction) {
+ /**
+ * Triggered whenever the history changes, either through user navigation
+ * or programmatic changes. It transforms the raw browser history changes
+ * into `RouteInfo` objects, which are needed Ionic's animations and
+ * navigation patterns.
+ *
+ * @param location The current location object from the history.
+ * @param action The action that triggered the history change.
+ */
+ const handleHistoryChange = (location: HistoryLocation, action: HistoryAction) => {
let leavingLocationInfo: RouteInfo;
- if (this.incomingRouteParams) {
- if (this.incomingRouteParams.routeAction === 'replace') {
- leavingLocationInfo = this.locationHistory.previous();
+ /**
+ * A programmatic navigation was triggered.
+ * e.g., ``, `history.push()`, or `handleNavigate()`
+ */
+ if (incomingRouteParams.current) {
+ /**
+ * The current history entry is overwritten, so the previous entry
+ * is the one we are leaving.
+ */
+ if (incomingRouteParams.current?.routeAction === 'replace') {
+ leavingLocationInfo = locationHistory.current.previous();
} else {
- leavingLocationInfo = this.locationHistory.current();
+ // If the action is 'push' or 'pop', we want to use the current route.
+ leavingLocationInfo = locationHistory.current.current();
}
} else {
- leavingLocationInfo = this.locationHistory.current();
+ /**
+ * An external navigation was triggered
+ * e.g., browser back/forward button or direct link
+ *
+ * The leaving location is the current route.
+ */
+ leavingLocationInfo = locationHistory.current.current();
}
const leavingUrl = leavingLocationInfo.pathname + leavingLocationInfo.search;
if (leavingUrl !== location.pathname) {
- if (!this.incomingRouteParams) {
+ if (!incomingRouteParams.current) {
+ // Determine if the destination is a tab route by checking if it matches
+ // the pattern of tab routes (containing /tabs/ in the path)
+ const isTabRoute = /\/tabs(\/|$)/.test(location.pathname);
+ const tabToUse = isTabRoute ? currentTab.current : undefined;
+
+ // If we're leaving tabs entirely, clear the current tab
+ if (!isTabRoute && currentTab.current) {
+ currentTab.current = undefined;
+ }
+
+ /**
+ * A `REPLACE` action can be triggered by React Router's
+ * `` component.
+ */
if (action === 'REPLACE') {
- this.incomingRouteParams = {
+ incomingRouteParams.current = {
routeAction: 'replace',
routeDirection: 'none',
- tab: this.currentTab,
+ tab: tabToUse,
};
}
+ /**
+ * A `POP` action can be triggered by the browser's back/forward
+ * button.
+ */
if (action === 'POP') {
- const currentRoute = this.locationHistory.current();
+ const currentRoute = locationHistory.current.current();
+ /**
+ * Check if the current route was "pushed" by a previous route
+ * (indicates a linear history path).
+ */
if (currentRoute && currentRoute.pushedByRoute) {
- const prevInfo = this.locationHistory.findLastLocation(currentRoute);
- this.incomingRouteParams = { ...prevInfo, routeAction: 'pop', routeDirection: 'back' };
+ const prevInfo = locationHistory.current.findLastLocation(currentRoute);
+ incomingRouteParams.current = { ...prevInfo, routeAction: 'pop', routeDirection: 'back' };
+ // It's a non-linear history path like a direct link.
} else {
- this.incomingRouteParams = {
+ incomingRouteParams.current = {
routeAction: 'pop',
routeDirection: 'none',
- tab: this.currentTab,
+ tab: tabToUse,
};
}
}
- if (!this.incomingRouteParams) {
- this.incomingRouteParams = {
+ if (!incomingRouteParams.current) {
+ const state = location.state as LocationState | null;
+ incomingRouteParams.current = {
routeAction: 'push',
- routeDirection: location.state?.direction || 'forward',
- routeOptions: location.state?.routerOptions,
- tab: this.currentTab,
+ routeDirection: state?.direction || 'forward',
+ routeOptions: state?.routerOptions,
+ tab: tabToUse,
};
}
}
let routeInfo: RouteInfo;
- if (this.incomingRouteParams?.id) {
+ // If we're navigating away from tabs to a non-tab route, clear the current tab
+ if (!/\/tabs(\/|$)/.test(location.pathname) && currentTab.current) {
+ currentTab.current = undefined;
+ }
+
+ /**
+ * An existing id indicates that it's re-activating an existing route.
+ * e.g., tab switching or navigating back to a previous route
+ */
+ if (incomingRouteParams.current?.id) {
routeInfo = {
- ...(this.incomingRouteParams as RouteInfo),
+ ...(incomingRouteParams.current as RouteInfo),
lastPathname: leavingLocationInfo.pathname,
};
- this.locationHistory.add(routeInfo);
+ locationHistory.current.add(routeInfo);
+ /**
+ * A new route is being created since it's not re-activating
+ * an existing route.
+ */
} else {
const isPushed =
- this.incomingRouteParams.routeAction === 'push' && this.incomingRouteParams.routeDirection === 'forward';
+ incomingRouteParams.current?.routeAction === 'push' &&
+ incomingRouteParams.current.routeDirection === 'forward';
routeInfo = {
id: generateId('routeInfo'),
- ...this.incomingRouteParams,
- lastPathname: leavingLocationInfo.pathname,
- pathname: location.pathname,
+ ...incomingRouteParams.current,
+ lastPathname: leavingLocationInfo.pathname, // The URL we just came from
+ pathname: location.pathname, // The current (destination) URL
search: location.search,
- params: this.props.match.params,
+ params: incomingRouteParams.current?.params
+ ? filterUndefinedParams(incomingRouteParams.current.params as RouteParams)
+ : {},
prevRouteLastPathname: leavingLocationInfo.lastPathname,
};
if (isPushed) {
- routeInfo.tab = leavingLocationInfo.tab;
+ // Only inherit tab from leaving route if we don't already have one.
+ // This preserves tab context for same-tab navigation while allowing cross-tab navigation.
+ routeInfo.tab = routeInfo.tab || leavingLocationInfo.tab;
routeInfo.pushedByRoute = leavingLocationInfo.pathname;
+ // Triggered by a browser back button or handleNavigateBack.
} else if (routeInfo.routeAction === 'pop') {
- const r = this.locationHistory.findLastLocation(routeInfo);
+ // Find the route that pushed this one.
+ const r = locationHistory.current.findLastLocation(routeInfo);
routeInfo.pushedByRoute = r?.pushedByRoute;
+ // Navigating to a new tab.
} else if (routeInfo.routeAction === 'push' && routeInfo.tab !== leavingLocationInfo.tab) {
- // If we are switching tabs grab the last route info for the tab and use its pushedByRoute
- const lastRoute = this.locationHistory.getCurrentRouteInfoForTab(routeInfo.tab);
- routeInfo.pushedByRoute = lastRoute?.pushedByRoute;
+ /**
+ * If we are switching tabs grab the last route info for the
+ * tab and use its `pushedByRoute`.
+ */
+ const lastRoute = locationHistory.current.getCurrentRouteInfoForTab(routeInfo.tab);
+ // This helps maintain correct back stack behavior within tabs.
+ // If this is the first time entering this tab from a different context,
+ // use the leaving route's pathname as the pushedByRoute to maintain the back stack.
+ routeInfo.pushedByRoute = lastRoute?.pushedByRoute ?? leavingLocationInfo.pathname;
+ // Triggered by `history.replace()` or a `` component, etc.
} else if (routeInfo.routeAction === 'replace') {
- // Make sure to set the lastPathname, etc.. to the current route so the page transitions out
- const currentRouteInfo = this.locationHistory.current();
+ /**
+ * Make sure to set the `lastPathname`, etc.. to the current route
+ * so the page transitions out.
+ */
+ const currentRouteInfo = locationHistory.current.current();
/**
- * If going from /home to /child, then replacing from
- * /child to /home, we don't want the route info to
- * say that /home was pushed by /home which is not correct.
+ * Special handling for `replace` to ensure correct `pushedByRoute`
+ * and `lastPathname`.
+ *
+ * If going from `/home` to `/child`, then replacing from
+ * `/child` to `/home`, we don't want the route info to
+ * say that `/home` was pushed by `/home` which is not correct.
*/
const currentPushedBy = currentRouteInfo?.pushedByRoute;
const pushedByRoute =
@@ -198,58 +309,126 @@ class IonRouterInner extends React.PureComponent {
routeInfo.routeAnimation = routeInfo.routeAnimation || currentRouteInfo?.routeAnimation;
}
- this.locationHistory.add(routeInfo);
+ locationHistory.current.add(routeInfo);
}
-
- this.setState({
- routeInfo,
- });
+ setRouteInfo(routeInfo);
}
- this.incomingRouteParams = undefined;
- }
+ incomingRouteParams.current = null;
+ };
/**
- * history@4.x uses goBack(), history@5.x uses back()
- * TODO: If support for React Router <=5 is dropped
- * this logic is no longer needed. We can just
- * assume back() is available.
+ * Resets the specified tab to its initial, root route.
+ *
+ * @param tab The tab to reset.
+ * @param originalHref The original href for the tab.
+ * @param originalRouteOptions The original route options for the tab.
*/
- handleNativeBack() {
- const history = this.props.history as any;
- const goBack = history.goBack || history.back;
- goBack();
- }
+ const handleResetTab = (tab: string, originalHref: string, originalRouteOptions: any) => {
+ const routeInfo = locationHistory.current.getFirstRouteInfoForTab(tab);
+ if (routeInfo) {
+ const newRouteInfo = { ...routeInfo };
+ newRouteInfo.pathname = originalHref;
+ newRouteInfo.routeOptions = originalRouteOptions;
+ incomingRouteParams.current = { ...newRouteInfo, routeAction: 'pop', routeDirection: 'back' };
+ navigate(newRouteInfo.pathname + (newRouteInfo.search || ''));
+ }
+ };
- handleNavigate(
- path: string,
- routeAction: RouteAction,
- routeDirection?: RouterDirection,
- routeAnimation?: AnimationBuilder,
- routeOptions?: any,
- tab?: string
- ) {
- this.incomingRouteParams = Object.assign(this.incomingRouteParams || {}, {
- routeAction,
- routeDirection,
- routeOptions,
- routeAnimation,
- tab,
- });
+ /**
+ * Handles tab changes.
+ *
+ * @param tab The tab to switch to.
+ * @param path The new path for the tab.
+ * @param routeOptions Additional route options.
+ */
+ const handleChangeTab = (tab: string, path?: string, routeOptions?: any) => {
+ if (!path) {
+ return;
+ }
- if (routeAction === 'push') {
- this.props.history.push(path);
+ const routeInfo = locationHistory.current.getCurrentRouteInfoForTab(tab);
+ const [pathname, search] = path.split('?');
+ // User has navigated to the current tab before.
+ if (routeInfo) {
+ const routeParams = {
+ ...routeInfo,
+ routeAction: 'push' as RouteAction,
+ routeDirection: 'none' as RouterDirection,
+ };
+ /**
+ * User is navigating to the same tab.
+ * e.g., `/tabs/home` → `/tabs/home`
+ */
+ if (routeInfo.pathname === pathname) {
+ incomingRouteParams.current = {
+ ...routeParams,
+ routeOptions,
+ };
+
+ navigate(routeInfo.pathname + (routeInfo.search || ''));
+ /**
+ * User is navigating to a different tab.
+ * e.g., `/tabs/home` → `/tabs/settings`
+ */
+ } else {
+ incomingRouteParams.current = {
+ ...routeParams,
+ pathname,
+ search: search ? '?' + search : undefined,
+ routeOptions,
+ };
+
+ navigate(pathname + (search ? '?' + search : ''));
+ }
+ // User has not navigated to this tab before.
} else {
- this.props.history.replace(path);
+ handleNavigate(pathname, 'push', 'none', undefined, routeOptions, tab);
}
- }
+ };
- handleNavigateBack(defaultHref: string | RouteInfo = '/', routeAnimation?: AnimationBuilder) {
+ /**
+ * Set the current active tab in `locationHistory`.
+ * This is crucial for maintaining tab history since each tab has
+ * its own navigation stack.
+ *
+ * @param tab The tab to set as active.
+ */
+ const handleSetCurrentTab = (tab: string) => {
+ currentTab.current = tab;
+ const ri = { ...locationHistory.current.current() };
+ if (ri.tab !== tab) {
+ ri.tab = tab;
+ locationHistory.current.update(ri);
+ }
+ };
+
+ /**
+ * Handles the native back button press.
+ * It's usually called when a user presses the platform-native back action.
+ */
+ const handleNativeBack = () => {
+ navigate(-1);
+ };
+
+ /**
+ * Used to manage the back navigation within the Ionic React's routing
+ * system. It's deeply integrated with Ionic's view lifecycle, animations,
+ * and its custom history tracking (`locationHistory`) to provide a
+ * native-like transition and maintain correct application state.
+ *
+ * @param defaultHref The fallback URL to navigate to if there's no
+ * previous entry in the `locationHistory` stack.
+ * @param routeAnimation A custom animation builder to override the
+ * default "back" animation.
+ */
+ const handleNavigateBack = (defaultHref: string | RouteInfo = '/', routeAnimation?: AnimationBuilder) => {
const config = getConfig();
defaultHref = defaultHref ? defaultHref : config && config.get('backButtonDefaultHref' as any);
- const routeInfo = this.locationHistory.current();
+ const routeInfo = locationHistory.current.current();
+ // It's a linear navigation.
if (routeInfo && routeInfo.pushedByRoute) {
- const prevInfo = this.locationHistory.findLastLocation(routeInfo);
+ const prevInfo = locationHistory.current.findLastLocation(routeInfo);
if (prevInfo) {
/**
* This needs to be passed to handleNavigate
@@ -257,84 +436,141 @@ class IonRouterInner extends React.PureComponent {
* will be overridden.
*/
const incomingAnimation = routeAnimation || routeInfo.routeAnimation;
- this.incomingRouteParams = {
+ incomingRouteParams.current = {
...prevInfo,
routeAction: 'pop',
routeDirection: 'back',
routeAnimation: incomingAnimation,
};
- if (
- routeInfo.lastPathname === routeInfo.pushedByRoute ||
- /**
- * We need to exclude tab switches/tab
- * context changes here because tabbed
- * navigation is not linear, but router.back()
- * will go back in a linear fashion.
- */
- (prevInfo.pathname === routeInfo.pushedByRoute && routeInfo.tab === '' && prevInfo.tab === '')
- ) {
+ /**
+ * Check if it's a simple linear back navigation (not tabbed).
+ * e.g., `/home` → `/settings` → back to `/home`
+ */
+ const condition1 = routeInfo.lastPathname === routeInfo.pushedByRoute;
+ const condition2 = prevInfo.pathname === routeInfo.pushedByRoute && routeInfo.tab === '' && prevInfo.tab === '';
+ if (condition1 || condition2) {
+ navigate(-1);
+ } else {
/**
- * history@4.x uses goBack(), history@5.x uses back()
- * TODO: If support for React Router <=5 is dropped
- * this logic is no longer needed. We can just
- * assume back() is available.
+ * It's a non-linear back navigation.
+ * e.g., direct link or tab switch or nested navigation with redirects
*/
- const history = this.props.history as any;
- const goBack = history.goBack || history.back;
- goBack();
- } else {
- this.handleNavigate(prevInfo.pathname + (prevInfo.search || ''), 'pop', 'back', incomingAnimation);
+ handleNavigate(prevInfo.pathname + (prevInfo.search || ''), 'pop', 'back', incomingAnimation);
}
+ /**
+ * `pushedByRoute` exists, but no corresponding previous entry in
+ * the history stack.
+ */
} else {
- this.handleNavigate(defaultHref as string, 'pop', 'back', routeAnimation);
+ handleNavigate(defaultHref as string, 'pop', 'back', routeAnimation);
}
+ /**
+ * No `pushedByRoute`
+ * e.g., initial page load
+ */
} else {
- this.handleNavigate(defaultHref as string, 'pop', 'back', routeAnimation);
+ handleNavigate(defaultHref as string, 'pop', 'back', routeAnimation);
}
- }
+ };
- handleResetTab(tab: string, originalHref: string, originalRouteOptions: any) {
- const routeInfo = this.locationHistory.getFirstRouteInfoForTab(tab);
- if (routeInfo) {
- const newRouteInfo = { ...routeInfo };
- newRouteInfo.pathname = originalHref;
- newRouteInfo.routeOptions = originalRouteOptions;
- this.incomingRouteParams = { ...newRouteInfo, routeAction: 'pop', routeDirection: 'back' };
- this.props.history.push(newRouteInfo.pathname + (newRouteInfo.search || ''));
- }
- }
+ /**
+ * Used to programmatically navigate through the app.
+ *
+ * @param path The path to navigate to.
+ * @param routeAction The action to take (push, replace, etc.).
+ * @param routeDirection The direction of the navigation (forward,
+ * back, etc.).
+ * @param routeAnimation The animation to use for the transition.
+ * @param routeOptions Additional options for the route.
+ * @param tab The tab to navigate to, if applicable.
+ */
+ const handleNavigate = (
+ path: string,
+ routeAction: RouteAction,
+ routeDirection?: RouterDirection,
+ routeAnimation?: AnimationBuilder,
+ routeOptions?: any,
+ tab?: string
+ ) => {
+ const normalizedRouteDirection =
+ routeAction === 'push' && routeDirection === undefined ? 'forward' : routeDirection;
- handleSetCurrentTab(tab: string) {
- this.currentTab = tab;
- const ri = { ...this.locationHistory.current() };
- if (ri.tab !== tab) {
- ri.tab = tab;
- this.locationHistory.update(ri);
+ // When navigating from tabs context, we need to determine if the destination
+ // is also within tabs. If not, we should clear the tab context.
+ let navigationTab = tab;
+
+ // If no explicit tab is provided and we're in a tab context,
+ // check if the destination path is outside of the current tab context
+ if (!tab && currentTab.current && path) {
+ // Get the current route info to understand where we are
+ const currentRoute = locationHistory.current.current();
+
+ // If we're navigating from a tab route to a completely different path structure,
+ // we should clear the tab context. This is a simplified check that assumes
+ // tab routes share a common parent path.
+ if (currentRoute && currentRoute.pathname) {
+ // Extract the base tab path (e.g., /routing/tabs from /routing/tabs/home)
+ const tabBaseMatch = currentRoute.pathname.match(/^(.*\/tabs)/);
+ if (tabBaseMatch) {
+ const tabBasePath = tabBaseMatch[1];
+ // If the new path doesn't start with the tab base path, we're leaving tabs
+ if (!path.startsWith(tabBasePath)) {
+ currentTab.current = undefined;
+ navigationTab = undefined;
+ } else {
+ // Still within tabs, preserve the tab context
+ navigationTab = currentTab.current;
+ }
+ }
+ }
}
- }
- render() {
- return (
-
-
- {this.props.children}
-
-
- );
- }
-}
+ const baseParams = incomingRouteParams.current ?? {};
+ incomingRouteParams.current = {
+ ...baseParams,
+ routeAction,
+ routeDirection: normalizedRouteDirection,
+ routeOptions,
+ routeAnimation,
+ tab: navigationTab,
+ };
+
+ navigate(path, { replace: routeAction !== 'push' });
+ };
+
+ const routeMangerContextValue: RouteManagerContextState = {
+ canGoBack: () => locationHistory.current.canGoBack(),
+ clearOutlet: viewStack.current.clear,
+ findViewItemByPathname: viewStack.current.findViewItemByPathname,
+ getChildrenToRender: viewStack.current.getChildrenToRender,
+ getViewItemsForOutlet: viewStack.current.getViewItemsForOutlet.bind(viewStack.current),
+ goBack: () => handleNavigateBack(),
+ createViewItem: viewStack.current.createViewItem,
+ findViewItemByRouteInfo: viewStack.current.findViewItemByRouteInfo,
+ findLeavingViewItemByRouteInfo: viewStack.current.findLeavingViewItemByRouteInfo,
+ addViewItem: viewStack.current.add,
+ unMountViewItem: viewStack.current.remove,
+ };
+
+ return (
+
+
+ {children}
+
+
+ );
+};
-export const IonRouter = withRouter(IonRouterInner);
IonRouter.displayName = 'IonRouter';
diff --git a/packages/react-router/src/ReactRouter/ReactRouterViewStack.tsx b/packages/react-router/src/ReactRouter/ReactRouterViewStack.tsx
index 92036790203..32598cb22ba 100644
--- a/packages/react-router/src/ReactRouter/ReactRouterViewStack.tsx
+++ b/packages/react-router/src/ReactRouter/ReactRouterViewStack.tsx
@@ -1,186 +1,806 @@
+/**
+ * `ReactRouterViewStack` is a custom navigation manager used in Ionic React
+ * apps to map React Router route elements (such as ``) to "view
+ * items" that Ionic can manage in a view stack. This is critical to maintain
+ * Ionic’s animation, lifecycle, and history behavior across views.
+ */
+
import type { RouteInfo, ViewItem } from '@ionic/react';
-import { IonRoute, ViewLifeCycleManager, ViewStacks, generateId } from '@ionic/react';
+import { IonRoute, ViewLifeCycleManager, ViewStacks } from '@ionic/react';
import React from 'react';
+import type { PathMatch } from 'react-router';
+import { Navigate, UNSAFE_RouteContext as RouteContext } from 'react-router-dom';
+
+import { analyzeRouteChildren, computeParentPath } from './utils/computeParentPath';
+import { derivePathnameToMatch, matchPath } from './utils/pathMatching';
+import { normalizePathnameForComparison } from './utils/pathNormalization';
+import { extractRouteChildren, isNavigateElement } from './utils/routeElements';
+import { sortViewsBySpecificity } from './utils/viewItemUtils';
+
+/**
+ * Delay in milliseconds before removing a Navigate view item after a redirect.
+ * This ensures the redirect navigation completes before the view is removed.
+ */
+const NAVIGATE_REDIRECT_DELAY_MS = 100;
+
+/**
+ * Delay in milliseconds before cleaning up a view without an IonPage element.
+ * This double-checks that the view is truly not needed before removal.
+ */
+const VIEW_CLEANUP_DELAY_MS = 200;
+
+const createDefaultMatch = (
+ fullPathname: string,
+ routeProps: { path?: string; caseSensitive?: boolean; end?: boolean; index?: boolean }
+): PathMatch => {
+ const isIndexRoute = !!routeProps.index;
+ const patternPath = routeProps.path ?? '';
+ const pathnameBase = fullPathname === '' ? '/' : fullPathname;
+ const computedEnd =
+ routeProps.end !== undefined ? routeProps.end : patternPath !== '' ? !patternPath.endsWith('*') : true;
+
+ return {
+ params: {},
+ pathname: isIndexRoute ? '' : fullPathname,
+ pathnameBase,
+ pattern: {
+ path: patternPath,
+ caseSensitive: routeProps.caseSensitive ?? false,
+ end: isIndexRoute ? true : computedEnd,
+ },
+ };
+};
+
+const computeRelativeToParent = (pathname: string, parentPath?: string): string | null => {
+ if (!parentPath) return null;
+ const normalizedParent = normalizePathnameForComparison(parentPath);
+ const normalizedPathname = normalizePathnameForComparison(pathname);
+
+ if (normalizedPathname === normalizedParent) {
+ return '';
+ }
+
+ const withSlash = normalizedParent === '/' ? '/' : normalizedParent + '/';
+ if (normalizedPathname.startsWith(withSlash)) {
+ return normalizedPathname.slice(withSlash.length);
+ }
+ return null;
+};
-import { matchPath } from './utils/matchPath';
+const resolveIndexRouteMatch = (
+ viewItem: ViewItem,
+ pathname: string,
+ parentPath?: string
+): PathMatch | null => {
+ if (!viewItem.routeData?.childProps?.index) {
+ return null;
+ }
+
+ // Prefer computing against the parent path when available to align with RRv6 semantics
+ const relative = computeRelativeToParent(pathname, parentPath);
+ if (relative !== null) {
+ // Index routes match only when there is no remaining path
+ if (relative === '' || relative === '/') {
+ return createDefaultMatch(parentPath || pathname, viewItem.routeData.childProps);
+ }
+ return null;
+ }
+
+ // Fallback: use previously computed match base for equality check
+ const previousMatch = viewItem.routeData?.match;
+ if (!previousMatch) {
+ return null;
+ }
+
+ const normalizedPathname = normalizePathnameForComparison(pathname);
+ const normalizedBase = normalizePathnameForComparison(previousMatch.pathnameBase || previousMatch.pathname || '');
+
+ return normalizedPathname === normalizedBase ? previousMatch : null;
+};
export class ReactRouterViewStack extends ViewStacks {
+ private viewItemCounter = 0;
+
constructor() {
super();
- this.createViewItem = this.createViewItem.bind(this);
- this.findViewItemByRouteInfo = this.findViewItemByRouteInfo.bind(this);
- this.findLeavingViewItemByRouteInfo = this.findLeavingViewItemByRouteInfo.bind(this);
- this.getChildrenToRender = this.getChildrenToRender.bind(this);
- this.findViewItemByPathname = this.findViewItemByPathname.bind(this);
}
- createViewItem(outletId: string, reactElement: React.ReactElement, routeInfo: RouteInfo, page?: HTMLElement) {
+ /**
+ * Creates a new view item for the given outlet and react route element.
+ * Associates route props with the matched route path for further lookups.
+ */
+ createViewItem = (outletId: string, reactElement: React.ReactElement, routeInfo: RouteInfo, page?: HTMLElement) => {
+ const routePath = reactElement.props.path || '';
+
+ // Check if we already have a view item for this exact route that we can reuse
+ // Include wildcard routes like tabs/* since they should be reused
+ // Also check unmounted items that might have been preserved for browser navigation
+ const existingViewItem = this.getViewItemsForOutlet(outletId).find((v) => {
+ const existingRouteProps = v.reactElement?.props ?? {};
+ const existingPath = existingRouteProps.path || '';
+ const existingElement = existingRouteProps.element;
+ const newElement = reactElement.props.element;
+ const existingIsIndexRoute = !!existingRouteProps.index;
+ const newIsIndexRoute = !!reactElement.props.index;
+
+ // For Navigate components, match by destination
+ const existingIsNavigate = React.isValidElement(existingElement) && existingElement.type === Navigate;
+ const newIsNavigate = React.isValidElement(newElement) && newElement.type === Navigate;
+ if (existingIsNavigate && newIsNavigate) {
+ const existingTo = (existingElement.props as { to?: string })?.to;
+ const newTo = (newElement.props as { to?: string })?.to;
+ if (existingTo === newTo) {
+ return true;
+ }
+ }
+
+ if (existingIsIndexRoute && newIsIndexRoute) {
+ return true;
+ }
+
+ // Reuse view items with the same path
+ // Special case: reuse tabs/* and other specific wildcard routes
+ // Don't reuse index routes (empty path) or generic catch-all wildcards (*)
+ if (existingPath === routePath && existingPath !== '' && existingPath !== '*') {
+ // Parameterized routes need pathname matching to ensure /details/1 and /details/2
+ // get separate view items. For wildcard routes (e.g., user/:userId/*), compare
+ // pathnameBase to allow child path changes while preserving the parent view.
+ const hasParams = routePath.includes(':');
+ const isWildcard = routePath.includes('*');
+ if (hasParams) {
+ if (isWildcard) {
+ const existingPathnameBase = v.routeData?.match?.pathnameBase;
+ const newMatch = matchComponent(reactElement, routeInfo.pathname, false);
+ const newPathnameBase = newMatch?.pathnameBase;
+ if (existingPathnameBase !== newPathnameBase) {
+ return false;
+ }
+ } else {
+ const existingPathname = v.routeData?.match?.pathname;
+ if (existingPathname !== routeInfo.pathname) {
+ return false;
+ }
+ }
+ }
+ return true;
+ }
+ // Also reuse specific wildcard routes like tabs/*
+ if (existingPath === routePath && existingPath.endsWith('/*') && existingPath !== '/*') {
+ return true;
+ }
+ return false;
+ });
+
+ if (existingViewItem) {
+ // Update and ensure the existing view item is properly configured
+ existingViewItem.reactElement = reactElement;
+ existingViewItem.mount = true;
+ existingViewItem.ionPageElement = page || existingViewItem.ionPageElement;
+ const updatedMatch =
+ matchComponent(reactElement, routeInfo.pathname, false) ||
+ existingViewItem.routeData?.match ||
+ createDefaultMatch(routeInfo.pathname, reactElement.props);
+
+ existingViewItem.routeData = {
+ match: updatedMatch,
+ childProps: reactElement.props,
+ lastPathname: existingViewItem.routeData?.lastPathname, // Preserve navigation history
+ };
+ return existingViewItem;
+ }
+
+ this.viewItemCounter++;
+ const id = `${outletId}-${this.viewItemCounter}`;
+
const viewItem: ViewItem = {
- id: generateId('viewItem'),
+ id,
outletId,
ionPageElement: page,
reactElement,
mount: true,
- ionRoute: false,
+ ionRoute: true,
};
if (reactElement.type === IonRoute) {
- viewItem.ionRoute = true;
viewItem.disableIonPageManagement = reactElement.props.disableIonPageManagement;
}
+ const initialMatch =
+ matchComponent(reactElement, routeInfo.pathname, true) ||
+ createDefaultMatch(routeInfo.pathname, reactElement.props);
+
viewItem.routeData = {
- match: matchPath({
- pathname: routeInfo.pathname,
- componentProps: reactElement.props,
- }),
+ match: initialMatch,
childProps: reactElement.props,
};
+ this.add(viewItem);
+
return viewItem;
- }
+ };
+
+ /**
+ * Renders a ViewLifeCycleManager for the given view item.
+ * Handles cleanup if the view no longer matches.
+ *
+ * - Deactivates view if it no longer matches the current route
+ * - Wraps the route element in to support nested routing and ensure remounting
+ * - Adds a unique key to so React Router remounts routes when switching
+ */
+ private renderViewItem = (viewItem: ViewItem, routeInfo: RouteInfo, parentPath?: string) => {
+ const routePath = viewItem.reactElement.props.path || '';
+ let match = matchComponent(viewItem.reactElement, routeInfo.pathname);
+
+ if (!match) {
+ const indexMatch = resolveIndexRouteMatch(viewItem, routeInfo.pathname, parentPath);
+ if (indexMatch) {
+ match = indexMatch;
+ }
+ }
+
+ // For parameterized routes, check if this is a navigation to a different path instance
+ // In that case, we should NOT reuse this view - a new view should be created
+ const isParameterRoute = routePath.includes(':');
+ const previousMatch = viewItem.routeData?.match;
+ const isSamePath = match?.pathname === previousMatch?.pathname;
+
+ // Flag to indicate this view should not be reused for this different parameterized path
+ const shouldSkipForDifferentParam = isParameterRoute && match && previousMatch && !isSamePath;
+
+ // Don't deactivate views automatically - let the StackManager handle view lifecycle
+ // This preserves views in the stack for navigation history like native apps
+ // Views will be hidden/shown by the StackManager's transition logic instead of being unmounted
+
+ // Special handling for Navigate components - they should unmount after redirecting
+ const elementComponent = viewItem.reactElement?.props?.element;
+ const isNavigateComponent = isNavigateElement(elementComponent);
+
+ if (isNavigateComponent) {
+ // Navigate components should only be mounted when they match
+ // Once they redirect (no longer match), they should be removed completely
+ // IMPORTANT: For index routes, we need to check indexMatch too since matchComponent
+ // may not properly match index routes without explicit parent path context
+ const indexMatch = viewItem.routeData?.childProps?.index
+ ? resolveIndexRouteMatch(viewItem, routeInfo.pathname, parentPath)
+ : null;
+ const hasValidMatch = match || indexMatch;
+
+ if (!hasValidMatch && viewItem.mount) {
+ viewItem.mount = false;
+ // Schedule removal of the Navigate view item after a short delay
+ // This ensures the redirect completes before removal
+ setTimeout(() => {
+ this.remove(viewItem);
+ }, NAVIGATE_REDIRECT_DELAY_MS);
+ }
+ }
+
+ // Components that don't have IonPage elements and no longer match should be cleaned up
+ // BUT we need to be careful not to remove them if they're part of browser navigation history
+ // This handles components that perform immediate actions like programmatic navigation
+ // EXCEPTION: Navigate components should ALWAYS remain mounted until they redirect
+ // since they need to be rendered to trigger the navigation
+ if (!match && viewItem.mount && !viewItem.ionPageElement && !isNavigateComponent) {
+ // Check if this view item should be preserved for browser navigation
+ // We'll keep it if it was recently active (within the last navigation)
+ const shouldPreserve =
+ viewItem.routeData.lastPathname === routeInfo.pathname ||
+ viewItem.routeData.match?.pathname === routeInfo.lastPathname;
+
+ if (!shouldPreserve) {
+ // This view item doesn't match and doesn't have an IonPage
+ // It's likely a utility component that performs an action and navigates away
+ viewItem.mount = false;
+ // Schedule removal to allow it to be recreated on next navigation
+ setTimeout(() => {
+ // Double-check before removing - the view might be needed again
+ const stillNotNeeded = !viewItem.mount && !viewItem.ionPageElement;
+ if (stillNotNeeded) {
+ this.remove(viewItem);
+ }
+ }, VIEW_CLEANUP_DELAY_MS);
+ } else {
+ // Preserve it but unmount it for now
+ viewItem.mount = false;
+ }
+ }
+
+ // Reactivate view if it matches but was previously deactivated
+ // Don't reactivate if this is a parameterized route navigating to a different path instance
+ if (match && !viewItem.mount && !shouldSkipForDifferentParam) {
+ viewItem.mount = true;
+ viewItem.routeData.match = match;
+ }
+
+ // Deactivate wildcard routes and catch-all routes (empty path) when we have specific route matches
+ // This prevents "Not found" or fallback pages from showing alongside valid routes
+ if (routePath === '*' || routePath === '') {
+ // Check if any other view in this outlet has a match for the current route
+ const hasSpecificMatch = this.getViewItemsForOutlet(viewItem.outletId).some((v) => {
+ if (v.id === viewItem.id) return false; // Skip self
+ const vRoutePath = v.reactElement?.props?.path || '';
+ if (vRoutePath === '*' || vRoutePath === '') return false; // Skip other wildcard/empty routes
+
+ // Check if this view item would match the current route
+ const vMatch = v.reactElement ? matchComponent(v.reactElement, routeInfo.pathname) : null;
+ return !!vMatch;
+ });
+
+ if (hasSpecificMatch) {
+ viewItem.mount = false;
+ // Also hide the ion-page element immediately to prevent visual overlap
+ if (viewItem.ionPageElement) {
+ viewItem.ionPageElement.classList.add('ion-page-hidden');
+ viewItem.ionPageElement.setAttribute('aria-hidden', 'true');
+ }
+ }
+ }
+
+ const routeElement = React.cloneElement(viewItem.reactElement);
+ const componentElement = routeElement.props.element;
+ // Don't update match for parameterized routes navigating to different path instances
+ // This preserves the original match so that findViewItemByPath can correctly skip this view
+ if (match && viewItem.routeData.match !== match && !shouldSkipForDifferentParam) {
+ viewItem.routeData.match = match;
+ }
+ const routeMatch = shouldSkipForDifferentParam ? viewItem.routeData?.match : match || viewItem.routeData?.match;
+
+ return (
+
+ {(parentContext) => {
+ const parentMatches = parentContext?.matches ?? [];
+ let accumulatedParentParams = parentMatches.reduce>(
+ (acc, match) => {
+ return { ...acc, ...match.params };
+ },
+ {}
+ );
+
+ // If parentMatches is empty, try to extract params from view items in other outlets.
+ // This handles cases where React context propagation doesn't work as expected
+ // for nested router outlets.
+ if (parentMatches.length === 0 && Object.keys(accumulatedParentParams).length === 0) {
+ const allViewItems = this.getAllViewItems();
+ for (const otherViewItem of allViewItems) {
+ // Skip view items from the same outlet
+ if (otherViewItem.outletId === viewItem.outletId) continue;
+
+ // Check if this view item's route could match the current pathname
+ const otherMatch = otherViewItem.routeData?.match;
+ if (otherMatch && otherMatch.params && Object.keys(otherMatch.params).length > 0) {
+ // Check if the current pathname starts with this view item's matched pathname
+ const matchedPathname = otherMatch.pathnameBase || otherMatch.pathname;
+ if (matchedPathname && routeInfo.pathname.startsWith(matchedPathname)) {
+ accumulatedParentParams = { ...accumulatedParentParams, ...otherMatch.params };
+ }
+ }
+ }
+ }
+
+ const combinedParams = {
+ ...accumulatedParentParams,
+ ...(routeMatch?.params ?? {}),
+ };
+
+ // For relative route paths, we need to compute an absolute pathnameBase
+ // by combining the parent's pathnameBase with the matched portion
+ let absolutePathnameBase = routeMatch?.pathnameBase || routeInfo.pathname;
+ const routePath = routeElement.props.path;
+ const isRelativePath = routePath && !routePath.startsWith('/');
+ const isIndexRoute = !!routeElement.props.index;
+
+ if (isRelativePath || isIndexRoute) {
+ // Get the parent's pathnameBase to build the absolute path
+ const parentPathnameBase =
+ parentMatches.length > 0 ? parentMatches[parentMatches.length - 1].pathnameBase : '/';
+
+ // For relative paths, the matchPath returns a relative pathnameBase
+ // We need to make it absolute by prepending the parent's base
+ if (routeMatch?.pathnameBase && isRelativePath) {
+ // Strip leading slash if present in the relative match
+ const relativeBase = routeMatch.pathnameBase.startsWith('/')
+ ? routeMatch.pathnameBase.slice(1)
+ : routeMatch.pathnameBase;
+
+ absolutePathnameBase =
+ parentPathnameBase === '/' ? `/${relativeBase}` : `${parentPathnameBase}/${relativeBase}`;
+ } else if (isIndexRoute) {
+ // Index routes should use the parent's base as their base
+ absolutePathnameBase = parentPathnameBase;
+ }
+ }
+
+ const contextMatches = [
+ ...parentMatches,
+ {
+ params: combinedParams,
+ pathname: routeMatch?.pathname || routeInfo.pathname,
+ pathnameBase: absolutePathnameBase,
+ route: {
+ id: viewItem.id,
+ path: routeElement.props.path,
+ element: componentElement,
+ index: !!routeElement.props.index,
+ caseSensitive: routeElement.props.caseSensitive,
+ hasErrorBoundary: false,
+ },
+ },
+ ];
+
+ const routeContextValue = parentContext
+ ? {
+ ...parentContext,
+ matches: contextMatches,
+ }
+ : {
+ outlet: null,
+ matches: contextMatches,
+ isDataRoute: false,
+ };
- getChildrenToRender(outletId: string, ionRouterOutlet: React.ReactElement, routeInfo: RouteInfo) {
+ return (
+ this.remove(viewItem)}
+ >
+ {componentElement}
+
+ );
+ }}
+
+ );
+ };
+
+ /**
+ * Re-renders all active view items for the specified outlet.
+ * Ensures React elements are updated with the latest match.
+ *
+ * 1. Iterates through children of IonRouterOutlet
+ * 2. Updates each matching viewItem with the current child React element
+ * (important for updating props or changes to elements)
+ * 3. Returns a list of React components that will be rendered inside the outlet
+ * Each view is wrapped in to manage lifecycle and rendering
+ */
+ getChildrenToRender = (outletId: string, ionRouterOutlet: React.ReactElement, routeInfo: RouteInfo) => {
const viewItems = this.getViewItemsForOutlet(outletId);
- // Sync latest routes with viewItems
+ // Determine parentPath for nested outlets to properly evaluate index routes
+ let parentPath: string | undefined = undefined;
+ try {
+ // Only attempt parent path computation for non-root outlets
+ if (outletId !== 'routerOutlet') {
+ const routeChildren = extractRouteChildren(ionRouterOutlet.props.children);
+ const { hasRelativeRoutes, hasIndexRoute, hasWildcardRoute } = analyzeRouteChildren(routeChildren);
+
+ if (hasRelativeRoutes || hasIndexRoute) {
+ const result = computeParentPath({
+ currentPathname: routeInfo.pathname,
+ outletMountPath: undefined,
+ routeChildren,
+ hasRelativeRoutes,
+ hasIndexRoute,
+ hasWildcardRoute,
+ });
+ parentPath = result.parentPath;
+ }
+ }
+ } catch (e) {
+ // Non-fatal: if we fail to compute parentPath, fall back to previous behavior
+ }
+
+ // Sync child elements with stored viewItems (e.g. to reflect new props)
React.Children.forEach(ionRouterOutlet.props.children, (child: React.ReactElement) => {
- const viewItem = viewItems.find((v) => {
- return matchComponent(child, v.routeData.childProps.path || v.routeData.childProps.from);
- });
- if (viewItem) {
- viewItem.reactElement = child;
+ // Ensure the child is a valid React element since we
+ // might have whitespace strings or other non-element children
+ if (React.isValidElement(child)) {
+ // Find view item by exact path match to avoid wildcard routes overwriting specific routes
+ const childPath = (child.props as any).path;
+ const viewItem = viewItems.find((v) => {
+ const viewItemPath = v.reactElement?.props?.path;
+ // Only update if paths match exactly (prevents wildcard routes from overwriting specific routes)
+ return viewItemPath === childPath;
+ });
+ if (viewItem) {
+ viewItem.reactElement = child;
+ }
}
});
- const children = viewItems.map((viewItem) => {
- let clonedChild;
- if (viewItem.ionRoute && !viewItem.disableIonPageManagement) {
- clonedChild = (
- this.remove(viewItem)}
- >
- {React.cloneElement(viewItem.reactElement, {
- computedMatch: viewItem.routeData.match,
- })}
-
- );
- } else {
- const match = matchComponent(viewItem.reactElement, routeInfo.pathname);
- clonedChild = (
- this.remove(viewItem)}
- >
- {React.cloneElement(viewItem.reactElement, {
- computedMatch: viewItem.routeData.match,
- })}
-
- );
-
- if (!match && viewItem.routeData.match) {
- viewItem.routeData.match = undefined;
- viewItem.mount = false;
+ // Filter out duplicate view items by ID (but keep all mounted items)
+ const uniqueViewItems = viewItems.filter((viewItem, index, array) => {
+ // Remove duplicates by ID (keep first occurrence)
+ const isFirstOccurrence = array.findIndex((v) => v.id === viewItem.id) === index;
+ return isFirstOccurrence;
+ });
+
+ // Filter out unmounted Navigate components to prevent them from being rendered
+ // and triggering unwanted redirects
+ const renderableViewItems = uniqueViewItems.filter((viewItem) => {
+ const elementComponent = viewItem.reactElement?.props?.element;
+ const isNavigateComponent = isNavigateElement(elementComponent);
+
+ // Exclude unmounted Navigate components from rendering
+ if (isNavigateComponent && !viewItem.mount) {
+ return false;
+ }
+
+ // Filter out views that are unmounted, have no ionPageElement, and don't match the current route.
+ // These are "stale" views from previous routes that should not be rendered.
+ // Views WITH ionPageElement are handled by the normal lifecycle events.
+ // Views that MATCH the current route should be kept (they might be transitioning).
+ if (!viewItem.mount && !viewItem.ionPageElement) {
+ // Check if this view's route path matches the current pathname
+ const viewRoutePath = viewItem.reactElement?.props?.path as string | undefined;
+ if (viewRoutePath) {
+ // First try exact match using matchComponent
+ const routeMatch = matchComponent(viewItem.reactElement, routeInfo.pathname);
+ if (routeMatch) {
+ // View matches current route, keep it
+ return true;
+ }
+
+ // For parent routes (like /multiple-tabs or /routing), check if current pathname
+ // starts with this route's path. This handles views with IonSplitPane/IonTabs
+ // that don't have IonPage but should remain mounted while navigating within their children.
+ const normalizedViewPath = normalizePathnameForComparison(viewRoutePath.replace(/\/?\*$/, '')); // Remove trailing wildcard
+ const normalizedCurrentPath = normalizePathnameForComparison(routeInfo.pathname);
+
+ // Check if current pathname is within this view's route hierarchy
+ const isWithinRouteHierarchy =
+ normalizedCurrentPath === normalizedViewPath || normalizedCurrentPath.startsWith(normalizedViewPath + '/');
+
+ if (!isWithinRouteHierarchy) {
+ // View is outside current route hierarchy, remove it
+ setTimeout(() => {
+ this.remove(viewItem);
+ }, 0);
+ return false;
+ }
}
}
- return clonedChild;
+ return true;
});
- return children;
- }
- findViewItemByRouteInfo(routeInfo: RouteInfo, outletId?: string, updateMatch?: boolean) {
+ const renderedItems = renderableViewItems.map((viewItem) => this.renderViewItem(viewItem, routeInfo, parentPath));
+ return renderedItems;
+ };
+
+ /**
+ * Finds a view item matching the current route, optionally updating its match state.
+ */
+ findViewItemByRouteInfo = (routeInfo: RouteInfo, outletId?: string, updateMatch?: boolean) => {
const { viewItem, match } = this.findViewItemByPath(routeInfo.pathname, outletId);
const shouldUpdateMatch = updateMatch === undefined || updateMatch === true;
if (shouldUpdateMatch && viewItem && match) {
viewItem.routeData.match = match;
}
return viewItem;
- }
+ };
- findLeavingViewItemByRouteInfo(routeInfo: RouteInfo, outletId?: string, mustBeIonRoute = true) {
- const { viewItem } = this.findViewItemByPath(routeInfo.lastPathname!, outletId, mustBeIonRoute);
+ /**
+ * Finds the view item that was previously active before a route change.
+ */
+ findLeavingViewItemByRouteInfo = (routeInfo: RouteInfo, outletId?: string, mustBeIonRoute = true) => {
+ // If the lastPathname is not set, we cannot find a leaving view item
+ if (!routeInfo.lastPathname) {
+ return undefined;
+ }
+
+ const { viewItem } = this.findViewItemByPath(routeInfo.lastPathname, outletId, mustBeIonRoute);
return viewItem;
- }
+ };
- findViewItemByPathname(pathname: string, outletId?: string) {
+ /**
+ * Finds a view item by pathname only, used in simpler queries.
+ */
+ findViewItemByPathname = (pathname: string, outletId?: string) => {
const { viewItem } = this.findViewItemByPath(pathname, outletId);
return viewItem;
- }
+ };
/**
- * Returns the matching view item and the match result for a given pathname.
+ * Core function that matches a given pathname against all view items.
+ * Returns both the matched view item and match metadata.
*/
- private findViewItemByPath(pathname: string, outletId?: string, mustBeIonRoute?: boolean) {
+ private findViewItemByPath(pathname: string, outletId?: string, mustBeIonRoute?: boolean, allowDefaultMatch = true) {
let viewItem: ViewItem | undefined;
- let match: ReturnType | undefined;
+ let match: PathMatch | null = null;
let viewStack: ViewItem[];
if (outletId) {
- viewStack = this.getViewItemsForOutlet(outletId);
+ viewStack = sortViewsBySpecificity(this.getViewItemsForOutlet(outletId));
viewStack.some(matchView);
- if (!viewItem) {
- viewStack.some(matchDefaultRoute);
- }
+ if (!viewItem && allowDefaultMatch) viewStack.some(matchDefaultRoute);
} else {
- const viewItems = this.getAllViewItems();
+ const viewItems = sortViewsBySpecificity(this.getAllViewItems());
viewItems.some(matchView);
- if (!viewItem) {
- viewItems.some(matchDefaultRoute);
- }
+ if (!viewItem && allowDefaultMatch) viewItems.some(matchDefaultRoute);
}
+ // If we still have not found a view item for this outlet, try to find a matching
+ // view item across all outlets and adopt it into the current outlet. This helps
+ // recover when an outlet remounts and receives a new id, leaving views associated
+ // with the previous outlet id.
+ // Do not adopt across outlets; if we didn't find a view for this outlet,
+ // defer to route matching to create a new one.
+
return { viewItem, match };
+ /**
+ * Matches a route path with dynamic parameters (e.g. /tabs/:id)
+ */
function matchView(v: ViewItem) {
- if (mustBeIonRoute && !v.ionRoute) {
- return false;
+ if (mustBeIonRoute && !v.ionRoute) return false;
+
+ const viewItemPath = v.routeData.childProps.path || '';
+ const isIndexRoute = !!v.routeData.childProps.index;
+ const previousMatch = v.routeData?.match;
+ const result = v.reactElement ? matchComponent(v.reactElement, pathname) : null;
+
+ if (!result) {
+ const indexMatch = resolveIndexRouteMatch(v, pathname, undefined);
+ if (indexMatch) {
+ match = indexMatch;
+ viewItem = v;
+ return true;
+ }
}
- match = matchPath({
- pathname,
- componentProps: v.routeData.childProps,
- });
+ if (result) {
+ const hasParams = result.params && Object.keys(result.params).length > 0;
+ const isSamePath = result.pathname === previousMatch?.pathname;
+ const isWildcardRoute = viewItemPath.includes('*');
+ const isParameterRoute = viewItemPath.includes(':');
+
+ // Don't allow view items with undefined paths to match specific routes
+ // This prevents broken index route view items from interfering with navigation
+ if (!viewItemPath && !isIndexRoute && pathname !== '/' && pathname !== '') {
+ return false;
+ }
+
+ // For parameterized routes, check if we should reuse the view item.
+ // Wildcard routes (e.g., user/:userId/*) compare pathnameBase to allow
+ // child path changes while preserving the parent view.
+ if (isParameterRoute && !isSamePath) {
+ if (isWildcardRoute) {
+ const isSameBase = result.pathnameBase === previousMatch?.pathnameBase;
+ if (isSameBase) {
+ match = result;
+ viewItem = v;
+ return true;
+ }
+ }
+ return false;
+ }
- if (match) {
- /**
- * Even though we have a match from react-router, we do not know if the match
- * is for this specific view item.
- *
- * To validate this, we need to check if the path and url match the view item's route data.
- */
- const hasParameter = match.path.includes(':');
- if (!hasParameter || (hasParameter && match.url === v.routeData?.match?.url)) {
+ // For routes without params, or when navigating to the exact same path,
+ // or when there's no previous match, reuse the view item
+ if (!hasParams || isSamePath || !previousMatch) {
+ match = result;
+ viewItem = v;
+ return true;
+ }
+
+ // For wildcard routes (without params), only reuse if the pathname exactly matches
+ if (isWildcardRoute && isSamePath) {
+ match = result;
viewItem = v;
return true;
}
}
+
return false;
}
- function matchDefaultRoute(v: ViewItem) {
- // try to find a route that doesn't have a path or from prop, that will be our default route
- if (!v.routeData.childProps.path && !v.routeData.childProps.from) {
+ /**
+ * Matches a view with no path prop (default fallback route) or index route.
+ */
+ function matchDefaultRoute(v: ViewItem): boolean {
+ const childProps = v.routeData.childProps;
+
+ const isDefaultRoute = childProps.path === undefined || childProps.path === '';
+ const isIndexRoute = !!childProps.index;
+
+ if (isIndexRoute) {
+ const indexMatch = resolveIndexRouteMatch(v, pathname, undefined);
+ if (indexMatch) {
+ match = indexMatch;
+ viewItem = v;
+ return true;
+ }
+ return false;
+ }
+
+ if (isDefaultRoute) {
match = {
- path: pathname,
- url: pathname,
- isExact: true,
params: {},
+ pathname,
+ pathnameBase: pathname === '' ? '/' : pathname,
+ pattern: {
+ path: '',
+ caseSensitive: childProps.caseSensitive ?? false,
+ end: true,
+ },
};
viewItem = v;
return true;
}
+
return false;
}
}
+
+ /**
+ * Clean up old, unmounted view items to prevent memory leaks
+ */
+ private cleanupStaleViewItems = (outletId: string) => {
+ const viewItems = this.getViewItemsForOutlet(outletId);
+
+ // Keep only the most recent mounted views and a few unmounted ones for history
+ const maxUnmountedItems = 3;
+ const unmountedItems = viewItems.filter((v) => !v.mount);
+
+ if (unmountedItems.length > maxUnmountedItems) {
+ // Remove oldest unmounted items
+ const itemsToRemove = unmountedItems.slice(0, unmountedItems.length - maxUnmountedItems);
+ itemsToRemove.forEach((item) => {
+ this.remove(item);
+ });
+ }
+ };
+
+ /**
+ * Override add to prevent duplicate view items with the same ID in the same outlet
+ * But allow multiple view items for the same route path (for navigation history)
+ */
+ add = (viewItem: ViewItem) => {
+ const existingViewItem = this.getViewItemsForOutlet(viewItem.outletId).find((v) => v.id === viewItem.id);
+
+ if (existingViewItem) {
+ return;
+ }
+
+ super.add(viewItem);
+
+ this.cleanupStaleViewItems(viewItem.outletId);
+ };
+
+ /**
+ * Override remove
+ */
+ remove = (viewItem: ViewItem) => {
+ super.remove(viewItem);
+ };
}
-function matchComponent(node: React.ReactElement, pathname: string) {
- return matchPath({
- pathname,
- componentProps: node.props,
+/**
+ * Utility to apply matchPath to a React element and return its match state.
+ */
+function matchComponent(node: React.ReactElement, pathname: string, allowFallback = false) {
+ const routeProps = node?.props ?? {};
+ const routePath: string | undefined = routeProps.path;
+ const pathnameToMatch = derivePathnameToMatch(pathname, routePath);
+
+ const match = matchPath({
+ pathname: pathnameToMatch,
+ componentProps: routeProps,
});
+
+ if (match || !allowFallback) {
+ return match;
+ }
+
+ const isIndexRoute = !!routeProps.index;
+
+ if (isIndexRoute) {
+ return createDefaultMatch(pathname, routeProps);
+ }
+
+ if (!routePath || routePath === '') {
+ return createDefaultMatch(pathname, routeProps);
+ }
+
+ return null;
}
diff --git a/packages/react-router/src/ReactRouter/StackManager.tsx b/packages/react-router/src/ReactRouter/StackManager.tsx
index 708de651391..4ce73c561b0 100644
--- a/packages/react-router/src/ReactRouter/StackManager.tsx
+++ b/packages/react-router/src/ReactRouter/StackManager.tsx
@@ -1,24 +1,62 @@
+/**
+ * `StackManager` is responsible for managing page transitions, keeping track
+ * of views (pages), and ensuring that navigation behaves like native apps —
+ * particularly with animations and swipe gestures.
+ */
+
import type { RouteInfo, StackContextState, ViewItem } from '@ionic/react';
import { RouteManagerContext, StackContext, generateId, getConfig } from '@ionic/react';
import React from 'react';
+import { Route } from 'react-router-dom';
import { clonePageElement } from './clonePageElement';
-import { matchPath } from './utils/matchPath';
-
-// TODO(FW-2959): types
+import { analyzeRouteChildren, computeCommonPrefix, computeParentPath } from './utils/computeParentPath';
+import { derivePathnameToMatch, matchPath } from './utils/pathMatching';
+import { stripTrailingSlash } from './utils/pathNormalization';
+import { extractRouteChildren, getRoutesChildren, isNavigateElement } from './utils/routeElements';
+
+/**
+ * Delay in milliseconds before unmounting a view after a transition completes.
+ * This ensures the page transition animation finishes before the view is removed.
+ */
+const VIEW_UNMOUNT_DELAY_MS = 250;
+
+/**
+ * Delay in milliseconds to wait for an IonPage element to be mounted before
+ * proceeding with a page transition.
+ */
+const ION_PAGE_WAIT_TIMEOUT_MS = 50;
interface StackManagerProps {
routeInfo: RouteInfo;
+ id?: string;
}
-// eslint-disable-next-line @typescript-eslint/no-empty-interface
-interface StackManagerState {}
-
const isViewVisible = (el: HTMLElement) =>
!el.classList.contains('ion-page-invisible') && !el.classList.contains('ion-page-hidden');
-export class StackManager extends React.PureComponent {
- id: string;
+/**
+ * Hides an ion-page element by adding hidden class and aria attribute.
+ */
+const hideIonPageElement = (element: HTMLElement | undefined): void => {
+ if (element) {
+ element.classList.add('ion-page-hidden');
+ element.setAttribute('aria-hidden', 'true');
+ }
+};
+
+/**
+ * Shows an ion-page element by removing hidden class and aria attribute.
+ */
+const showIonPageElement = (element: HTMLElement | undefined): void => {
+ if (element) {
+ element.classList.remove('ion-page-hidden');
+ element.removeAttribute('aria-hidden');
+ }
+};
+
+export class StackManager extends React.PureComponent {
+ id: string; // Unique id for the router outlet aka outletId
context!: React.ContextType;
ionRouterOutlet?: React.ReactElement;
routerOutletElement: HTMLIonRouterOutletElement | undefined;
@@ -32,17 +70,416 @@ export class StackManager extends React.PureComponent;
+ private outOfScopeUnmountTimeout?: ReturnType;
+ /**
+ * Track the last transition's entering and leaving view IDs to prevent
+ * duplicate transitions during rapid navigation (e.g., Navigate redirects)
+ */
+ private lastTransition?: { enteringId: string; leavingId?: string };
constructor(props: StackManagerProps) {
super(props);
this.registerIonPage = this.registerIonPage.bind(this);
this.transitionPage = this.transitionPage.bind(this);
this.handlePageTransition = this.handlePageTransition.bind(this);
- this.id = generateId('routerOutlet');
+ this.id = props.id || `routerOutlet-${generateId('routerOutlet')}`;
this.prevProps = undefined;
this.skipTransition = false;
}
+ private outletMountPath: string | undefined = undefined;
+
+ /**
+ * Determines the parent path that was matched to reach this outlet.
+ * This helps with nested routing in React Router 6.
+ *
+ * The algorithm finds the shortest parent path where a route matches the remaining path.
+ * Priority: specific routes > wildcard routes > index routes (only at mount point)
+ */
+ private getParentPath(): string | undefined {
+ const currentPathname = this.props.routeInfo.pathname;
+
+ // If this outlet previously established a mount path and the current
+ // pathname is outside of that scope, do not attempt to re-compute a new
+ // parent path. This prevents out-of-scope outlets from "adopting"
+ // unrelated routes (e.g., matching their index route under /overlays).
+ if (this.outletMountPath && !currentPathname.startsWith(this.outletMountPath)) {
+ return undefined;
+ }
+
+ // If this is a nested outlet (has an explicit ID like "main"),
+ // we need to figure out what part of the path was already matched
+ if (this.id !== 'routerOutlet' && this.ionRouterOutlet) {
+ const routeChildren = extractRouteChildren(this.ionRouterOutlet.props.children);
+ const { hasRelativeRoutes, hasIndexRoute, hasWildcardRoute } = analyzeRouteChildren(routeChildren);
+
+ const result = computeParentPath({
+ currentPathname,
+ outletMountPath: this.outletMountPath,
+ routeChildren,
+ hasRelativeRoutes,
+ hasIndexRoute,
+ hasWildcardRoute,
+ });
+
+ // Update the outlet mount path if it was set
+ if (result.outletMountPath && !this.outletMountPath) {
+ this.outletMountPath = result.outletMountPath;
+ }
+
+ return result.parentPath;
+ }
+ return this.outletMountPath;
+ }
+
+ /**
+ * Finds the entering and leaving view items for a route transition,
+ * handling special redirect cases.
+ */
+ private findViewItems(routeInfo: RouteInfo): {
+ enteringViewItem: ViewItem | undefined;
+ leavingViewItem: ViewItem | undefined;
+ } {
+ const enteringViewItem = this.context.findViewItemByRouteInfo(routeInfo, this.id);
+ let leavingViewItem = this.context.findLeavingViewItemByRouteInfo(routeInfo, this.id);
+
+ // If we don't have a leaving view item, but the route info indicates
+ // that the user has routed from a previous path, then the leaving view
+ // can be found by the last known pathname.
+ if (!leavingViewItem && routeInfo.prevRouteLastPathname) {
+ leavingViewItem = this.context.findViewItemByPathname(routeInfo.prevRouteLastPathname, this.id);
+ }
+
+ // Special case for redirects: When a redirect happens inside a nested route,
+ // the entering and leaving view might be the same (the container route like tabs/*).
+ // In this case, we need to look at prevRouteLastPathname to find the actual
+ // view we're transitioning away from.
+ if (
+ enteringViewItem &&
+ leavingViewItem &&
+ enteringViewItem === leavingViewItem &&
+ routeInfo.routeAction === 'replace' &&
+ routeInfo.prevRouteLastPathname
+ ) {
+ const actualLeavingView = this.context.findViewItemByPathname(routeInfo.prevRouteLastPathname, this.id);
+ if (actualLeavingView && actualLeavingView !== enteringViewItem) {
+ leavingViewItem = actualLeavingView;
+ }
+ }
+
+ // Also check if we're in a redirect scenario where entering and leaving are different
+ // but we still need to handle the actual previous view.
+ if (
+ enteringViewItem &&
+ !leavingViewItem &&
+ routeInfo.routeAction === 'replace' &&
+ routeInfo.prevRouteLastPathname
+ ) {
+ const actualLeavingView = this.context.findViewItemByPathname(routeInfo.prevRouteLastPathname, this.id);
+ if (actualLeavingView && actualLeavingView !== enteringViewItem) {
+ leavingViewItem = actualLeavingView;
+ }
+ }
+
+ return { enteringViewItem, leavingViewItem };
+ }
+
+ /**
+ * Determines if the leaving view item should be unmounted after a transition.
+ */
+ private shouldUnmountLeavingView(
+ routeInfo: RouteInfo,
+ enteringViewItem: ViewItem | undefined,
+ leavingViewItem: ViewItem | undefined
+ ): boolean {
+ if (!leavingViewItem) {
+ return false;
+ }
+
+ if (routeInfo.routeAction === 'replace') {
+ return true;
+ }
+
+ const isForwardPush = routeInfo.routeAction === 'push' && (routeInfo as any).routeDirection === 'forward';
+ if (!isForwardPush && routeInfo.routeDirection !== 'none' && enteringViewItem !== leavingViewItem) {
+ return true;
+ }
+
+ return false;
+ }
+
+ /**
+ * Handles the case when the outlet is out of scope (current route is outside mount path).
+ * Returns true if the transition should be aborted.
+ */
+ private handleOutOfScopeOutlet(routeInfo: RouteInfo): boolean {
+ if (!this.outletMountPath || routeInfo.pathname.startsWith(this.outletMountPath)) {
+ return false;
+ }
+
+ // Clear any pending unmount timeout to avoid conflicts
+ if (this.outOfScopeUnmountTimeout) {
+ clearTimeout(this.outOfScopeUnmountTimeout);
+ this.outOfScopeUnmountTimeout = undefined;
+ }
+
+ // When an outlet is out of scope, unmount its views immediately
+ const allViewsInOutlet = this.context.getViewItemsForOutlet ? this.context.getViewItemsForOutlet(this.id) : [];
+
+ // Unmount and remove all views in this outlet immediately to avoid leftover content
+ allViewsInOutlet.forEach((viewItem) => {
+ hideIonPageElement(viewItem.ionPageElement);
+ this.context.unMountViewItem(viewItem);
+ });
+
+ this.forceUpdate();
+ return true;
+ }
+
+ /**
+ * Handles the case when this is a nested outlet with relative routes but no valid parent path.
+ * Returns true if the transition should be aborted.
+ */
+ private handleOutOfContextNestedOutlet(
+ parentPath: string | undefined,
+ leavingViewItem: ViewItem | undefined
+ ): boolean {
+ if (this.id === 'routerOutlet' || parentPath !== undefined || !this.ionRouterOutlet) {
+ return false;
+ }
+
+ const routesChildren =
+ getRoutesChildren(this.ionRouterOutlet.props.children) ?? this.ionRouterOutlet.props.children;
+ const routeChildren = React.Children.toArray(routesChildren).filter(
+ (child): child is React.ReactElement => React.isValidElement(child) && child.type === Route
+ );
+
+ const hasRelativeRoutes = routeChildren.some((route) => {
+ const path = route.props.path;
+ return path && !path.startsWith('/') && path !== '*';
+ });
+
+ if (hasRelativeRoutes) {
+ // Hide any visible views in this outlet since it's out of scope
+ hideIonPageElement(leavingViewItem?.ionPageElement);
+ if (leavingViewItem) {
+ leavingViewItem.mount = false;
+ }
+ this.forceUpdate();
+ return true;
+ }
+
+ return false;
+ }
+
+ /**
+ * Handles the case when a nested outlet has no matching route.
+ * Returns true if the transition should be aborted.
+ */
+ private handleNoMatchingRoute(
+ enteringRoute: React.ReactElement | undefined,
+ enteringViewItem: ViewItem | undefined,
+ leavingViewItem: ViewItem | undefined
+ ): boolean {
+ if (this.id === 'routerOutlet' || enteringRoute || enteringViewItem) {
+ return false;
+ }
+
+ // Hide any visible views in this outlet since it has no matching route
+ hideIonPageElement(leavingViewItem?.ionPageElement);
+ if (leavingViewItem) {
+ leavingViewItem.mount = false;
+ }
+ this.forceUpdate();
+ return true;
+ }
+
+ /**
+ * Handles the transition when entering view item has an ion-page element ready.
+ */
+ private handleReadyEnteringView(
+ routeInfo: RouteInfo,
+ enteringViewItem: ViewItem,
+ leavingViewItem: ViewItem | undefined,
+ shouldUnmountLeavingViewItem: boolean
+ ): void {
+ // Ensure the entering view is not hidden from previous navigations
+ showIonPageElement(enteringViewItem.ionPageElement);
+
+ // Handle same view item case (e.g., parameterized route changes)
+ if (enteringViewItem === leavingViewItem) {
+ const routePath = enteringViewItem.reactElement?.props?.path as string | undefined;
+ const isParameterizedRoute = routePath ? routePath.includes(':') : false;
+
+ if (isParameterizedRoute) {
+ // Refresh match metadata so the component receives updated params
+ const updatedMatch = matchComponent(enteringViewItem.reactElement, routeInfo.pathname, true);
+ if (updatedMatch) {
+ enteringViewItem.routeData.match = updatedMatch;
+ }
+
+ const enteringEl = enteringViewItem.ionPageElement;
+ if (enteringEl) {
+ enteringEl.classList.remove('ion-page-hidden', 'ion-page-invisible');
+ enteringEl.removeAttribute('aria-hidden');
+ }
+
+ this.forceUpdate();
+ return;
+ }
+ }
+
+ // Try to find leaving view using prev route info if still not found
+ if (!leavingViewItem && this.props.routeInfo.prevRouteLastPathname) {
+ leavingViewItem = this.context.findViewItemByPathname(this.props.routeInfo.prevRouteLastPathname, this.id);
+ }
+
+ // Skip transition if entering view is visible and leaving view is not
+ if (
+ enteringViewItem.ionPageElement &&
+ isViewVisible(enteringViewItem.ionPageElement) &&
+ leavingViewItem !== undefined &&
+ leavingViewItem.ionPageElement &&
+ !isViewVisible(leavingViewItem.ionPageElement)
+ ) {
+ return;
+ }
+
+ // Check for duplicate transition
+ const currentTransition = {
+ enteringId: enteringViewItem.id,
+ leavingId: leavingViewItem?.id,
+ };
+
+ if (
+ leavingViewItem &&
+ this.lastTransition &&
+ this.lastTransition.leavingId &&
+ this.lastTransition.enteringId === currentTransition.enteringId &&
+ this.lastTransition.leavingId === currentTransition.leavingId
+ ) {
+ return;
+ }
+
+ this.lastTransition = currentTransition;
+ this.transitionPage(routeInfo, enteringViewItem, leavingViewItem);
+
+ // Handle unmounting the leaving view
+ if (shouldUnmountLeavingViewItem && leavingViewItem && enteringViewItem !== leavingViewItem) {
+ leavingViewItem.mount = false;
+ this.handleLeavingViewUnmount(routeInfo, enteringViewItem, leavingViewItem);
+ }
+ }
+
+ /**
+ * Handles the delayed unmount of the leaving view item after a replace action.
+ */
+ private handleLeavingViewUnmount(routeInfo: RouteInfo, enteringViewItem: ViewItem, leavingViewItem: ViewItem): void {
+ if (routeInfo.routeAction !== 'replace' || !leavingViewItem.ionPageElement) {
+ return;
+ }
+
+ // Check if we should skip removal for nested outlet redirects
+ const enteringRoutePath = enteringViewItem.reactElement?.props?.path as string | undefined;
+ const leavingRoutePath = leavingViewItem.reactElement?.props?.path as string | undefined;
+ const isEnteringContainerRoute = enteringRoutePath && enteringRoutePath.endsWith('/*');
+ const isLeavingSpecificRoute =
+ leavingRoutePath &&
+ leavingRoutePath !== '' &&
+ leavingRoutePath !== '*' &&
+ !leavingRoutePath.endsWith('/*') &&
+ !leavingViewItem.reactElement?.props?.index;
+
+ // Skip removal only for container-to-container transitions
+ if (isEnteringContainerRoute && !isLeavingSpecificRoute) {
+ return;
+ }
+
+ const viewToUnmount = leavingViewItem;
+ setTimeout(() => {
+ this.context.unMountViewItem(viewToUnmount);
+ }, VIEW_UNMOUNT_DELAY_MS);
+ }
+
+ /**
+ * Handles the case when entering view has no ion-page element yet (waiting for render).
+ */
+ private handleWaitingForIonPage(
+ routeInfo: RouteInfo,
+ enteringViewItem: ViewItem,
+ leavingViewItem: ViewItem | undefined,
+ shouldUnmountLeavingViewItem: boolean
+ ): void {
+ const enteringRouteElement = enteringViewItem.reactElement?.props?.element;
+
+ // Handle Navigate components (they never render an IonPage)
+ if (isNavigateElement(enteringRouteElement)) {
+ this.waitingForIonPage = false;
+ if (this.ionPageWaitTimeout) {
+ clearTimeout(this.ionPageWaitTimeout);
+ this.ionPageWaitTimeout = undefined;
+ }
+ this.pendingPageTransition = false;
+
+ // Hide the leaving view immediately for Navigate redirects
+ hideIonPageElement(leavingViewItem?.ionPageElement);
+
+ // Don't unmount if entering and leaving are the same view item
+ if (shouldUnmountLeavingViewItem && leavingViewItem && enteringViewItem !== leavingViewItem) {
+ leavingViewItem.mount = false;
+ }
+
+ this.forceUpdate();
+ return;
+ }
+
+ // Hide leaving view while we wait for the entering view's IonPage to mount
+ hideIonPageElement(leavingViewItem?.ionPageElement);
+
+ this.waitingForIonPage = true;
+
+ if (this.ionPageWaitTimeout) {
+ clearTimeout(this.ionPageWaitTimeout);
+ }
+
+ this.ionPageWaitTimeout = setTimeout(() => {
+ this.ionPageWaitTimeout = undefined;
+
+ if (!this.waitingForIonPage) {
+ return;
+ }
+ this.waitingForIonPage = false;
+
+ const latestEnteringView = this.context.findViewItemByRouteInfo(routeInfo, this.id) ?? enteringViewItem;
+ const latestLeavingView = this.context.findLeavingViewItemByRouteInfo(routeInfo, this.id) ?? leavingViewItem;
+
+ if (latestEnteringView?.ionPageElement) {
+ this.transitionPage(routeInfo, latestEnteringView, latestLeavingView ?? undefined);
+
+ if (shouldUnmountLeavingViewItem && latestLeavingView && latestEnteringView !== latestLeavingView) {
+ latestLeavingView.mount = false;
+ }
+
+ this.forceUpdate();
+ }
+ }, ION_PAGE_WAIT_TIMEOUT_MS);
+
+ this.forceUpdate();
+ }
+
+ /**
+ * Gets the route info to use for finding views during swipe-to-go-back gestures.
+ * This pattern is used in multiple places in setupRouterOutlet.
+ */
+ private getSwipeBackRouteInfo(): RouteInfo {
+ const { routeInfo } = this.props;
+ return this.prevProps && this.prevProps.routeInfo.pathname === routeInfo.pushedByRoute
+ ? this.prevProps.routeInfo
+ : ({ pathname: routeInfo.pushedByRoute || '' } as RouteInfo);
+ }
+
componentDidMount() {
if (this.clearOutletTimeout) {
/**
@@ -76,121 +513,138 @@ export class StackManager extends React.PureComponent {
+ hideIonPageElement(viewItem.ionPageElement);
+ });
+
this.clearOutletTimeout = this.context.clearOutlet(this.id);
}
+ /**
+ * Sets the transition between pages within this router outlet.
+ * This function determines the entering and leaving views based on the
+ * provided route information and triggers the appropriate animation.
+ * It also handles scenarios like initial loads, back navigation, and
+ * navigation to the same view with different parameters.
+ *
+ * @param routeInfo It contains info about the current route,
+ * the previous route, and the action taken (e.g., push, replace).
+ *
+ * @returns A promise that resolves when the transition is complete.
+ * If no transition is needed or if the router outlet isn't ready,
+ * the Promise may resolve immediately.
+ */
async handlePageTransition(routeInfo: RouteInfo) {
+ // Wait for router outlet to mount
if (!this.routerOutletElement || !this.routerOutletElement.commit) {
- /**
- * The route outlet has not mounted yet. We need to wait for it to render
- * before we can transition the page.
- *
- * Set a flag to indicate that we should transition the page after
- * the component has updated.
- */
this.pendingPageTransition = true;
- } else {
- let enteringViewItem = this.context.findViewItemByRouteInfo(routeInfo, this.id);
- let leavingViewItem = this.context.findLeavingViewItemByRouteInfo(routeInfo, this.id);
+ return;
+ }
- if (!leavingViewItem && routeInfo.prevRouteLastPathname) {
- leavingViewItem = this.context.findViewItemByPathname(routeInfo.prevRouteLastPathname, this.id);
- }
+ // Find entering and leaving view items
+ const viewItems = this.findViewItems(routeInfo);
+ let enteringViewItem = viewItems.enteringViewItem;
+ const leavingViewItem = viewItems.leavingViewItem;
+ const shouldUnmountLeavingViewItem = this.shouldUnmountLeavingView(routeInfo, enteringViewItem, leavingViewItem);
- // Check if leavingViewItem should be unmounted
- if (leavingViewItem) {
- if (routeInfo.routeAction === 'replace') {
- leavingViewItem.mount = false;
- } else if (!(routeInfo.routeAction === 'push' && routeInfo.routeDirection === 'forward')) {
- if (routeInfo.routeDirection !== 'none' && enteringViewItem !== leavingViewItem) {
- leavingViewItem.mount = false;
- }
- } else if (routeInfo.routeOptions?.unmount) {
- leavingViewItem.mount = false;
- }
- }
+ // Get parent path for nested outlets
+ const parentPath = this.getParentPath();
- const enteringRoute = matchRoute(this.ionRouterOutlet?.props.children, routeInfo) as React.ReactElement;
+ // Handle out-of-scope outlet (route outside mount path)
+ if (this.handleOutOfScopeOutlet(routeInfo)) {
+ return;
+ }
- if (enteringViewItem) {
- enteringViewItem.reactElement = enteringRoute;
- } else if (enteringRoute) {
- enteringViewItem = this.context.createViewItem(this.id, enteringRoute, routeInfo);
- this.context.addViewItem(enteringViewItem);
- }
+ // Clear any pending out-of-scope unmount timeout
+ if (this.outOfScopeUnmountTimeout) {
+ clearTimeout(this.outOfScopeUnmountTimeout);
+ this.outOfScopeUnmountTimeout = undefined;
+ }
- if (enteringViewItem && enteringViewItem.ionPageElement) {
- /**
- * If the entering view item is the same as the leaving view item,
- * then we don't need to transition.
- */
- if (enteringViewItem === leavingViewItem) {
- /**
- * If the entering view item is the same as the leaving view item,
- * we are either transitioning using parameterized routes to the same view
- * or a parent router outlet is re-rendering as a result of React props changing.
- *
- * If the route data does not match the current path, the parent router outlet
- * is attempting to transition and we cancel the operation.
- */
- if (enteringViewItem.routeData.match.url !== routeInfo.pathname) {
- return;
- }
- }
+ // Handle nested outlet with relative routes but no valid parent path
+ if (this.handleOutOfContextNestedOutlet(parentPath, leavingViewItem)) {
+ return;
+ }
- /**
- * If there isn't a leaving view item, but the route info indicates
- * that the user has routed from a previous path, then we need
- * to find the leaving view item to transition between.
- */
- if (!leavingViewItem && this.props.routeInfo.prevRouteLastPathname) {
- leavingViewItem = this.context.findViewItemByPathname(this.props.routeInfo.prevRouteLastPathname, this.id);
- }
+ // Find the matching route element
+ const enteringRoute = findRouteByRouteInfo(
+ this.ionRouterOutlet?.props.children,
+ routeInfo,
+ parentPath
+ ) as React.ReactElement;
- /**
- * If the entering view is already visible and the leaving view is not, the transition does not need to occur.
- */
- if (
- isViewVisible(enteringViewItem.ionPageElement) &&
- leavingViewItem !== undefined &&
- !isViewVisible(leavingViewItem.ionPageElement!)
- ) {
- return;
- }
+ // Handle nested outlet with no matching route
+ if (this.handleNoMatchingRoute(enteringRoute, enteringViewItem, leavingViewItem)) {
+ return;
+ }
- /**
- * The view should only be transitioned in the following cases:
- * 1. Performing a replace or pop action, such as a swipe to go back gesture
- * to animation the leaving view off the screen.
- *
- * 2. Navigating between top-level router outlets, such as /page-1 to /page-2;
- * or navigating within a nested outlet, such as /tabs/tab-1 to /tabs/tab-2.
- *
- * 3. The entering view is an ion-router-outlet containing a page
- * matching the current route and that hasn't already transitioned in.
- *
- * This should only happen when navigating directly to a nested router outlet
- * route or on an initial page load (i.e. refreshing). In cases when loading
- * /tabs/tab-1, we need to transition the /tabs page element into the view.
- */
- this.transitionPage(routeInfo, enteringViewItem, leavingViewItem);
- } else if (leavingViewItem && !enteringRoute && !enteringViewItem) {
- // If we have a leavingView but no entering view/route, we are probably leaving to
- // another outlet, so hide this leavingView. We do it in a timeout to give time for a
- // transition to finish.
- // setTimeout(() => {
- if (leavingViewItem.ionPageElement) {
- leavingViewItem.ionPageElement.classList.add('ion-page-hidden');
- leavingViewItem.ionPageElement.setAttribute('aria-hidden', 'true');
- }
- // }, 250);
+ // Create or update the entering view item
+ if (enteringViewItem && enteringRoute) {
+ enteringViewItem.reactElement = enteringRoute;
+ } else if (enteringRoute) {
+ enteringViewItem = this.context.createViewItem(this.id, enteringRoute, routeInfo);
+ this.context.addViewItem(enteringViewItem);
+ }
+
+ // Handle transition based on ion-page element availability
+ if (enteringViewItem && enteringViewItem.ionPageElement) {
+ // Clear waiting state
+ if (this.waitingForIonPage) {
+ this.waitingForIonPage = false;
+ }
+ if (this.ionPageWaitTimeout) {
+ clearTimeout(this.ionPageWaitTimeout);
+ this.ionPageWaitTimeout = undefined;
}
- this.forceUpdate();
+ this.handleReadyEnteringView(routeInfo, enteringViewItem, leavingViewItem, shouldUnmountLeavingViewItem);
+ } else if (enteringViewItem && !enteringViewItem.ionPageElement) {
+ // Wait for ion-page to mount
+ this.handleWaitingForIonPage(routeInfo, enteringViewItem, leavingViewItem, shouldUnmountLeavingViewItem);
+ return;
+ } else if (!enteringViewItem && !enteringRoute) {
+ // No view or route found - likely leaving to another outlet
+ if (leavingViewItem) {
+ hideIonPageElement(leavingViewItem.ionPageElement);
+ if (shouldUnmountLeavingViewItem) {
+ leavingViewItem.mount = false;
+ }
+ }
}
+
+ this.forceUpdate();
}
+ /**
+ * Registers an `` DOM element with the `StackManager`.
+ * This is called when `` has been mounted.
+ *
+ * @param page The element of the rendered ``.
+ * @param routeInfo The route information that associates with ``.
+ */
registerIonPage(page: HTMLElement, routeInfo: RouteInfo) {
+ this.waitingForIonPage = false;
+ if (this.ionPageWaitTimeout) {
+ clearTimeout(this.ionPageWaitTimeout);
+ this.ionPageWaitTimeout = undefined;
+ }
+ this.pendingPageTransition = false;
const foundView = this.context.findViewItemByRouteInfo(routeInfo, this.id);
if (foundView) {
const oldPageElement = foundView.ionPageElement;
@@ -209,97 +663,66 @@ export class StackManager extends React.PureComponent`.
+ */
async setupRouterOutlet(routerOutlet: HTMLIonRouterOutletElement) {
const canStart = () => {
const config = getConfig();
+ // Check if swipe back is enabled in config (default to true for iOS mode)
const swipeEnabled = config && config.get('swipeBackEnabled', routerOutlet.mode === 'ios');
if (!swipeEnabled) {
return false;
}
const { routeInfo } = this.props;
+ const swipeBackRouteInfo = this.getSwipeBackRouteInfo();
+ const enteringViewItem = this.context.findViewItemByRouteInfo(swipeBackRouteInfo, this.id, false);
- const propsToUse =
- this.prevProps && this.prevProps.routeInfo.pathname === routeInfo.pushedByRoute
- ? this.prevProps.routeInfo
- : ({ pathname: routeInfo.pushedByRoute || '' } as any);
- const enteringViewItem = this.context.findViewItemByRouteInfo(propsToUse, this.id, false);
-
- return (
+ const canStartSwipe =
!!enteringViewItem &&
- /**
- * The root url '/' is treated as
- * the first view item (but is never mounted),
- * so we do not want to swipe back to the
- * root url.
- */
+ // The root url '/' is treated as the first view item (but is never mounted),
+ // so we do not want to swipe back to the root url.
enteringViewItem.mount &&
- /**
- * When on the first page (whatever view
- * you land on after the root url) it
- * is possible for findViewItemByRouteInfo to
- * return the exact same view you are currently on.
- * Make sure that we are not swiping back to the same
- * instances of a view.
- */
- enteringViewItem.routeData.match.path !== routeInfo.pathname
- );
+ // When on the first page it is possible for findViewItemByRouteInfo to
+ // return the exact same view you are currently on.
+ // Make sure that we are not swiping back to the same instances of a view.
+ enteringViewItem.routeData.match.pattern.path !== routeInfo.pathname;
+
+ return canStartSwipe;
};
const onStart = async () => {
const { routeInfo } = this.props;
-
- const propsToUse =
- this.prevProps && this.prevProps.routeInfo.pathname === routeInfo.pushedByRoute
- ? this.prevProps.routeInfo
- : ({ pathname: routeInfo.pushedByRoute || '' } as any);
- const enteringViewItem = this.context.findViewItemByRouteInfo(propsToUse, this.id, false);
+ const swipeBackRouteInfo = this.getSwipeBackRouteInfo();
+ const enteringViewItem = this.context.findViewItemByRouteInfo(swipeBackRouteInfo, this.id, false);
const leavingViewItem = this.context.findViewItemByRouteInfo(routeInfo, this.id, false);
- /**
- * When the gesture starts, kick off
- * a transition that is controlled
- * via a swipe gesture.
- */
+ // When the gesture starts, kick off a transition controlled via swipe gesture
if (enteringViewItem && leavingViewItem) {
await this.transitionPage(routeInfo, enteringViewItem, leavingViewItem, 'back', true);
}
return Promise.resolve();
};
+
const onEnd = (shouldContinue: boolean) => {
if (shouldContinue) {
+ // User finished the swipe gesture, so complete the back navigation
this.skipTransition = true;
-
this.context.goBack();
} else {
- /**
- * In the event that the swipe
- * gesture was aborted, we should
- * re-hide the page that was going to enter.
- */
+ // Swipe gesture was aborted - re-hide the page that was going to enter
const { routeInfo } = this.props;
-
- const propsToUse =
- this.prevProps && this.prevProps.routeInfo.pathname === routeInfo.pushedByRoute
- ? this.prevProps.routeInfo
- : ({ pathname: routeInfo.pushedByRoute || '' } as any);
- const enteringViewItem = this.context.findViewItemByRouteInfo(propsToUse, this.id, false);
+ const swipeBackRouteInfo = this.getSwipeBackRouteInfo();
+ const enteringViewItem = this.context.findViewItemByRouteInfo(swipeBackRouteInfo, this.id, false);
const leavingViewItem = this.context.findViewItemByRouteInfo(routeInfo, this.id, false);
- /**
- * Ionic React has a design defect where it
- * a) Unmounts the leaving view item when using parameterized routes
- * b) Considers the current view to be the entering view when using
- * parameterized routes
- *
- * As a result, we should not hide the view item here
- * as it will cause the current view to be hidden.
- */
+ // Don't hide if entering and leaving are the same (parameterized route edge case)
if (enteringViewItem !== leavingViewItem && enteringViewItem?.ionPageElement !== undefined) {
- const { ionPageElement } = enteringViewItem;
- ionPageElement.setAttribute('aria-hidden', 'true');
- ionPageElement.classList.add('ion-page-hidden');
+ hideIonPageElement(enteringViewItem.ionPageElement);
}
}
};
@@ -311,6 +734,18 @@ export class StackManager extends React.PureComponent {
+ // Callback triggers re-render when view items are modified during getChildrenToRender
this.forceUpdate();
});
@@ -405,13 +851,16 @@ export class StackManager extends React.PureComponent {
if (ionRouterOutlet.props.setRef) {
+ // Needed to handle external refs from devs.
ionRouterOutlet.props.setRef(node);
}
if (ionRouterOutlet.props.forwardedRef) {
+ // Needed to handle external refs from devs.
ionRouterOutlet.props.forwardedRef.current = node;
}
this.routerOutletElement = node;
const { ref } = ionRouterOutlet as any;
+ // Check for legacy refs.
if (typeof ref === 'function') {
ref(node);
}
@@ -430,38 +879,139 @@ export class StackManager extends React.PureComponent` node matching the current route info.
+ * If no `` can be matched, a fallback node is returned.
+ * Routes are prioritized by specificity (most specific first).
+ *
+ * @param node The root node to search for `` nodes.
+ * @param routeInfo The route information to match against.
+ * @param parentPath The parent path that was matched by the parent outlet (for nested routing)
+ */
+function findRouteByRouteInfo(node: React.ReactNode, routeInfo: RouteInfo, parentPath?: string) {
let matchedNode: React.ReactNode;
- React.Children.forEach(node as React.ReactElement, (child: React.ReactElement) => {
+ let fallbackNode: React.ReactNode;
+
+ // `` nodes are rendered inside of a node
+ const routesChildren = getRoutesChildren(node) ?? node;
+
+ // Collect all route children
+ const routeChildren = React.Children.toArray(routesChildren).filter(
+ (child): child is React.ReactElement => React.isValidElement(child) && child.type === Route
+ );
+
+ // Sort routes by specificity (most specific first)
+ const sortedRoutes = routeChildren.sort((a, b) => {
+ const pathA = a.props.path || '';
+ const pathB = b.props.path || '';
+
+ // Index routes come first
+ if (a.props.index && !b.props.index) return -1;
+ if (!a.props.index && b.props.index) return 1;
+
+ // Wildcard-only routes (*) should come LAST
+ const aIsWildcardOnly = pathA === '*';
+ const bIsWildcardOnly = pathB === '*';
+
+ if (!aIsWildcardOnly && bIsWildcardOnly) return -1;
+ if (aIsWildcardOnly && !bIsWildcardOnly) return 1;
+
+ // Exact matches (no wildcards/params) come before wildcard/param routes
+ const aHasWildcard = pathA.includes('*') || pathA.includes(':');
+ const bHasWildcard = pathB.includes('*') || pathB.includes(':');
+
+ if (!aHasWildcard && bHasWildcard) return -1;
+ if (aHasWildcard && !bHasWildcard) return 1;
+
+ // Among routes with same wildcard status, longer paths are more specific
+ if (pathA.length !== pathB.length) {
+ return pathB.length - pathA.length;
+ }
+
+ return 0;
+ });
+
+ // For nested routes in React Router 6, we need to extract the relative path
+ // that this outlet should be responsible for matching
+ let pathnameToMatch = routeInfo.pathname;
+
+ // Check if we have relative routes (routes that don't start with '/')
+ const hasRelativeRoutes = sortedRoutes.some((r) => r.props.path && !r.props.path.startsWith('/'));
+ const hasIndexRoute = sortedRoutes.some((r) => r.props.index);
+
+ // SIMPLIFIED: Trust React Router 6's matching more, compute relative path when parent is known
+ if ((hasRelativeRoutes || hasIndexRoute) && parentPath) {
+ const parentPrefix = parentPath.replace('/*', '');
+ const normalizedParent = stripTrailingSlash(parentPrefix);
+ const normalizedPathname = stripTrailingSlash(routeInfo.pathname);
+
+ // Only compute relative path if pathname is within parent scope
+ if (normalizedPathname.startsWith(normalizedParent + '/') || normalizedPathname === normalizedParent) {
+ const pathSegments = routeInfo.pathname.split('/').filter(Boolean);
+ const parentSegments = normalizedParent.split('/').filter(Boolean);
+ const relativeSegments = pathSegments.slice(parentSegments.length);
+ pathnameToMatch = relativeSegments.join('/'); // Empty string is valid for index routes
+ }
+ }
+
+ // Find the first matching route
+ for (const child of sortedRoutes) {
const match = matchPath({
- pathname: routeInfo.pathname,
+ pathname: pathnameToMatch,
componentProps: child.props,
});
+
if (match) {
matchedNode = child;
+ break;
}
- });
+ }
if (matchedNode) {
return matchedNode;
}
- // If we haven't found a node
- // try to find one that doesn't have a path or from prop, that will be our not found route
- React.Children.forEach(node as React.ReactElement, (child: React.ReactElement) => {
- if (!(child.props.path || child.props.from)) {
- matchedNode = child;
+
+ // If we haven't found a node, try to find one that doesn't have a path prop (fallback route)
+ // BUT only return the fallback if the current pathname is within the outlet's scope.
+ // For outlets with absolute paths, compute the common prefix to determine scope.
+ const absolutePathRoutes = routeChildren.filter((r) => r.props.path && r.props.path.startsWith('/'));
+
+ // Determine if pathname is within scope before returning fallback
+ let isPathnameInScope = true;
+
+ if (absolutePathRoutes.length > 0) {
+ // Find common prefix of all absolute paths to determine outlet scope
+ const absolutePaths = absolutePathRoutes.map((r) => r.props.path as string);
+ const commonPrefix = computeCommonPrefix(absolutePaths);
+
+ // If we have a common prefix, check if the current pathname is within that scope
+ if (commonPrefix && commonPrefix !== '/') {
+ isPathnameInScope = routeInfo.pathname.startsWith(commonPrefix);
}
- });
+ }
+
+ // Only look for fallback route if pathname is within scope
+ if (isPathnameInScope) {
+ for (const child of routeChildren) {
+ if (!child.props.path) {
+ fallbackNode = child;
+ break;
+ }
+ }
+ }
- return matchedNode;
+ return matchedNode ?? fallbackNode;
}
function matchComponent(node: React.ReactElement, pathname: string, forceExact?: boolean) {
+ const routePath: string | undefined = node?.props?.path;
+ const pathnameToMatch = derivePathnameToMatch(pathname, routePath);
+
return matchPath({
- pathname,
+ pathname: pathnameToMatch,
componentProps: {
...node.props,
- exact: forceExact,
+ end: forceExact,
},
});
}
diff --git a/packages/react-router/src/ReactRouter/utils/computeParentPath.ts b/packages/react-router/src/ReactRouter/utils/computeParentPath.ts
new file mode 100644
index 00000000000..3f47672a445
--- /dev/null
+++ b/packages/react-router/src/ReactRouter/utils/computeParentPath.ts
@@ -0,0 +1,259 @@
+import type React from 'react';
+
+import { matchPath } from './pathMatching';
+
+/**
+ * Finds the longest common prefix among an array of paths.
+ * Used to determine the scope of an outlet with absolute routes.
+ *
+ * @param paths An array of absolute path strings.
+ * @returns The common prefix shared by all paths.
+ */
+export const computeCommonPrefix = (paths: string[]): string => {
+ if (paths.length === 0) return '';
+ if (paths.length === 1) {
+ // For a single path, extract the directory-like prefix
+ // e.g., /dynamic-routes/home -> /dynamic-routes
+ const segments = paths[0].split('/').filter(Boolean);
+ if (segments.length > 1) {
+ return '/' + segments.slice(0, -1).join('/');
+ }
+ return '/' + segments[0];
+ }
+
+ // Split all paths into segments
+ const segmentArrays = paths.map((p) => p.split('/').filter(Boolean));
+ const minLength = Math.min(...segmentArrays.map((s) => s.length));
+
+ const commonSegments: string[] = [];
+ for (let i = 0; i < minLength; i++) {
+ const segment = segmentArrays[0][i];
+ // Skip segments with route parameters or wildcards
+ if (segment.includes(':') || segment.includes('*')) {
+ break;
+ }
+ const allMatch = segmentArrays.every((s) => s[i] === segment);
+ if (allMatch) {
+ commonSegments.push(segment);
+ } else {
+ break;
+ }
+ }
+
+ return commonSegments.length > 0 ? '/' + commonSegments.join('/') : '';
+};
+
+/**
+ * Checks if a route is a specific match (not wildcard or index).
+ *
+ * @param route The route element to check.
+ * @param remainingPath The remaining path to match against.
+ * @returns True if the route specifically matches the remaining path.
+ */
+export const isSpecificRouteMatch = (route: React.ReactElement, remainingPath: string): boolean => {
+ const routePath = route.props.path;
+ const isWildcardOnly = routePath === '*' || routePath === '/*';
+ const isIndex = route.props.index;
+
+ // Skip wildcards and index routes
+ if (isIndex || isWildcardOnly) {
+ return false;
+ }
+
+ return !!matchPath({
+ pathname: remainingPath,
+ componentProps: route.props,
+ });
+};
+
+/**
+ * Result of parent path computation.
+ */
+export interface ParentPathResult {
+ parentPath: string | undefined;
+ outletMountPath: string | undefined;
+}
+
+interface RouteAnalysis {
+ hasRelativeRoutes: boolean;
+ hasIndexRoute: boolean;
+ hasWildcardRoute: boolean;
+ routeChildren: React.ReactElement[];
+}
+
+/**
+ * Analyzes route children to determine their characteristics.
+ *
+ * @param routeChildren The route children to analyze.
+ * @returns Analysis of the route characteristics.
+ */
+export const analyzeRouteChildren = (routeChildren: React.ReactElement[]): RouteAnalysis => {
+ const hasRelativeRoutes = routeChildren.some((route) => {
+ const path = route.props.path;
+ return path && !path.startsWith('/') && path !== '*';
+ });
+
+ const hasIndexRoute = routeChildren.some((route) => route.props.index);
+
+ const hasWildcardRoute = routeChildren.some((route) => {
+ const routePath = route.props.path;
+ return routePath === '*' || routePath === '/*';
+ });
+
+ return { hasRelativeRoutes, hasIndexRoute, hasWildcardRoute, routeChildren };
+};
+
+interface ComputeParentPathOptions {
+ currentPathname: string;
+ outletMountPath: string | undefined;
+ routeChildren: React.ReactElement[];
+ hasRelativeRoutes: boolean;
+ hasIndexRoute: boolean;
+ hasWildcardRoute: boolean;
+}
+
+/**
+ * Computes the parent path for a nested outlet based on the current pathname
+ * and the outlet's route configuration.
+ *
+ * The algorithm finds the shortest parent path where a route matches the remaining path.
+ * Priority: specific routes > wildcard routes > index routes (only at mount point)
+ *
+ * @param options The options for computing the parent path.
+ * @returns The computed parent path result.
+ */
+export const computeParentPath = (options: ComputeParentPathOptions): ParentPathResult => {
+ const { currentPathname, outletMountPath, routeChildren, hasRelativeRoutes, hasIndexRoute, hasWildcardRoute } =
+ options;
+
+ // If this outlet previously established a mount path and the current
+ // pathname is outside of that scope, do not attempt to re-compute a new
+ // parent path.
+ if (outletMountPath && !currentPathname.startsWith(outletMountPath)) {
+ return { parentPath: undefined, outletMountPath };
+ }
+
+ if ((hasRelativeRoutes || hasIndexRoute) && currentPathname.includes('/')) {
+ const segments = currentPathname.split('/').filter(Boolean);
+
+ if (segments.length >= 1) {
+ // Find matches at each level, keeping track of the FIRST (shortest) match
+ let firstSpecificMatch: string | undefined = undefined;
+ let firstWildcardMatch: string | undefined = undefined;
+ let indexMatchAtMount: string | undefined = undefined;
+
+ for (let i = 1; i <= segments.length; i++) {
+ const parentPath = '/' + segments.slice(0, i).join('/');
+ const remainingPath = segments.slice(i).join('/');
+
+ // Check for specific (non-wildcard, non-index) route matches
+ const hasSpecificMatch = routeChildren.some((route) => isSpecificRouteMatch(route, remainingPath));
+ if (hasSpecificMatch && !firstSpecificMatch) {
+ firstSpecificMatch = parentPath;
+ // Found a specific match - this is our answer for non-index routes
+ break;
+ }
+
+ // Check if wildcard would match this remaining path
+ // Only if remaining is non-empty (wildcard needs something to match)
+ if (remainingPath !== '' && remainingPath !== '/' && hasWildcardRoute && !firstWildcardMatch) {
+ // Check if any specific route could plausibly match this remaining path
+ const remainingFirstSegment = remainingPath.split('/')[0];
+ const couldAnyRouteMatch = routeChildren.some((route) => {
+ const routePath = route.props.path as string | undefined;
+ if (!routePath || routePath === '*' || routePath === '/*') return false;
+ if (route.props.index) return false;
+
+ const routeFirstSegment = routePath.split('/')[0].replace(/[*:]/g, '');
+ if (!routeFirstSegment) return false;
+
+ // Check for prefix overlap (either direction)
+ return (
+ routeFirstSegment.startsWith(remainingFirstSegment.slice(0, 3)) ||
+ remainingFirstSegment.startsWith(routeFirstSegment.slice(0, 3))
+ );
+ });
+
+ // Only save wildcard match if no specific route could match
+ if (!couldAnyRouteMatch) {
+ firstWildcardMatch = parentPath;
+ // Continue looking - might find a specific match at a longer path
+ }
+ }
+
+ // Check for index route match when remaining path is empty
+ // BUT only at the outlet's mount path level
+ if ((remainingPath === '' || remainingPath === '/') && hasIndexRoute) {
+ // Index route matches when current path exactly matches the mount path
+ // If we already have an outletMountPath, index should only match there
+ if (outletMountPath) {
+ if (parentPath === outletMountPath) {
+ indexMatchAtMount = parentPath;
+ }
+ } else {
+ // No mount path set yet - index would establish this as mount path
+ // But only if we haven't found a better match
+ indexMatchAtMount = parentPath;
+ }
+ }
+ }
+
+ // Determine the best parent path:
+ // 1. Specific match (routes like tabs/*, favorites) - highest priority
+ // 2. Wildcard match (route path="*") - catches unmatched segments
+ // 3. Index match - only valid at the outlet's mount point, not deeper
+ let bestPath: string | undefined = undefined;
+
+ if (firstSpecificMatch) {
+ bestPath = firstSpecificMatch;
+ } else if (firstWildcardMatch) {
+ bestPath = firstWildcardMatch;
+ } else if (indexMatchAtMount) {
+ // Only use index match if no specific or wildcard matched
+ // This handles the case where pathname exactly matches the mount path
+ bestPath = indexMatchAtMount;
+ }
+
+ // Store the mount path when we first successfully match a route
+ let newOutletMountPath = outletMountPath;
+ if (!outletMountPath && bestPath) {
+ newOutletMountPath = bestPath;
+ }
+
+ // If we have a mount path, verify the current pathname is within scope
+ if (newOutletMountPath && !currentPathname.startsWith(newOutletMountPath)) {
+ return { parentPath: undefined, outletMountPath: newOutletMountPath };
+ }
+
+ return { parentPath: bestPath, outletMountPath: newOutletMountPath };
+ }
+ }
+
+ // Handle outlets with ONLY absolute routes (no relative routes or index routes)
+ // Compute the common prefix of all absolute routes to determine the outlet's scope
+ if (!hasRelativeRoutes && !hasIndexRoute) {
+ const absolutePathRoutes = routeChildren.filter((route) => {
+ const path = route.props.path;
+ return path && path.startsWith('/');
+ });
+
+ if (absolutePathRoutes.length > 0) {
+ const absolutePaths = absolutePathRoutes.map((r) => r.props.path as string);
+ const commonPrefix = computeCommonPrefix(absolutePaths);
+
+ if (commonPrefix && commonPrefix !== '/') {
+ // Set the mount path based on common prefix of absolute routes
+ const newOutletMountPath = outletMountPath || commonPrefix;
+
+ // Check if current pathname is within scope
+ if (!currentPathname.startsWith(commonPrefix)) {
+ return { parentPath: undefined, outletMountPath: newOutletMountPath };
+ }
+
+ return { parentPath: commonPrefix, outletMountPath: newOutletMountPath };
+ }
+ }
+ }
+
+ return { parentPath: outletMountPath, outletMountPath };
+};
diff --git a/packages/react-router/src/ReactRouter/utils/matchPath.ts b/packages/react-router/src/ReactRouter/utils/matchPath.ts
deleted file mode 100644
index 891eda08bb2..00000000000
--- a/packages/react-router/src/ReactRouter/utils/matchPath.ts
+++ /dev/null
@@ -1,47 +0,0 @@
-import { matchPath as reactRouterMatchPath } from 'react-router';
-
-interface MatchPathOptions {
- /**
- * The pathname to match against.
- */
- pathname: string;
- /**
- * The props to match against, they are identical to the matching props `Route` accepts.
- */
- componentProps: {
- path?: string;
- from?: string;
- component?: any;
- exact?: boolean;
- };
-}
-
-/**
- * @see https://v5.reactrouter.com/web/api/matchPath
- */
-export const matchPath = ({
- pathname,
- componentProps,
-}: MatchPathOptions): false | ReturnType => {
- const { exact, component } = componentProps;
-
- const path = componentProps.path || componentProps.from;
- /***
- * The props to match against, they are identical
- * to the matching props `Route` accepts. It could also be a string
- * or an array of strings as shortcut for `{ path }`.
- */
- const matchProps = {
- exact,
- path,
- component,
- };
-
- const match = reactRouterMatchPath(pathname, matchProps);
-
- if (!match) {
- return false;
- }
-
- return match;
-};
diff --git a/packages/react-router/src/ReactRouter/utils/pathMatching.ts b/packages/react-router/src/ReactRouter/utils/pathMatching.ts
new file mode 100644
index 00000000000..a0ab74164f7
--- /dev/null
+++ b/packages/react-router/src/ReactRouter/utils/pathMatching.ts
@@ -0,0 +1,162 @@
+import type { PathMatch } from 'react-router';
+import { matchPath as reactRouterMatchPath } from 'react-router-dom';
+
+/**
+ * Options for the matchPath function.
+ */
+interface MatchPathOptions {
+ /**
+ * The pathname to match against.
+ */
+ pathname: string;
+ /**
+ * The props to match against, they are identical to the matching props `Route` accepts.
+ */
+ componentProps: {
+ path?: string;
+ caseSensitive?: boolean;
+ end?: boolean;
+ index?: boolean;
+ };
+}
+
+/**
+ * The matchPath function is used only for matching paths, not rendering components or elements.
+ * @see https://reactrouter.com/v6/utils/match-path
+ */
+export const matchPath = ({ pathname, componentProps }: MatchPathOptions): PathMatch | null => {
+ const { path, index, ...restProps } = componentProps;
+
+ // Handle index routes
+ if (index && !path) {
+ // Index routes match when there's no additional path after the parent route
+ // For example, in a nested outlet at /routing/*, the index route matches
+ // when the relative path is empty (i.e., we're exactly at /routing)
+
+ // If pathname is empty or just "/", it should match the index route
+ if (pathname === '' || pathname === '/') {
+ return {
+ params: {},
+ pathname: pathname,
+ pathnameBase: pathname || '/',
+ pattern: {
+ path: '',
+ caseSensitive: false,
+ end: true,
+ },
+ };
+ }
+
+ // Otherwise, index routes don't match when there's additional path
+ return null;
+ }
+
+ if (!path) {
+ return null;
+ }
+
+ // For relative paths in nested routes (those that don't start with '/'),
+ // use React Router's matcher against a normalized path.
+ if (!path.startsWith('/')) {
+ const matchOptions: Parameters[0] = {
+ path: `/${path}`,
+ ...restProps,
+ };
+
+ if (matchOptions?.end === undefined) {
+ matchOptions.end = !path.endsWith('*');
+ }
+
+ const normalizedPathname = pathname.startsWith('/') ? pathname : `/${pathname}`;
+ const match = reactRouterMatchPath(matchOptions, normalizedPathname);
+
+ if (match) {
+ // Adjust the match to remove the leading '/' we added
+ return {
+ ...match,
+ pathname: pathname,
+ pathnameBase: match.pathnameBase === '/' ? '' : match.pathnameBase.slice(1),
+ pattern: {
+ ...match.pattern,
+ path: path,
+ },
+ };
+ }
+
+ // No match found
+ return null;
+ }
+
+ // For absolute paths, use React Router's matcher directly.
+ // React Router v6 routes default to `end: true` unless the pattern
+ // explicitly opts into wildcards with `*`. Mirror that behaviour so
+ // matching parity stays aligned with .
+ const matchOptions: Parameters[0] = {
+ path,
+ ...restProps,
+ };
+
+ if (matchOptions?.end === undefined) {
+ matchOptions.end = !path.endsWith('*');
+ }
+
+ return reactRouterMatchPath(matchOptions, pathname);
+};
+
+/**
+ * Determines the portion of a pathname that a given route pattern should match against.
+ * For absolute route patterns we return the full pathname. For relative patterns we
+ * strip off the already-matched parent segments so React Router receives the remainder.
+ */
+export const derivePathnameToMatch = (fullPathname: string, routePath?: string): string => {
+ if (!routePath || routePath === '' || routePath.startsWith('/')) {
+ return fullPathname;
+ }
+
+ const trimmedPath = fullPathname.startsWith('/') ? fullPathname.slice(1) : fullPathname;
+ if (!trimmedPath) {
+ return '';
+ }
+
+ const fullSegments = trimmedPath.split('/').filter(Boolean);
+ if (fullSegments.length === 0) {
+ return '';
+ }
+
+ const routeSegments = routePath.split('/').filter(Boolean);
+ if (routeSegments.length === 0) {
+ return trimmedPath;
+ }
+
+ const wildcardIndex = routeSegments.findIndex((segment) => segment === '*' || segment === '**');
+
+ if (wildcardIndex >= 0) {
+ const baseSegments = routeSegments.slice(0, wildcardIndex);
+ if (baseSegments.length === 0) {
+ return trimmedPath;
+ }
+
+ const startIndex = fullSegments.findIndex((_, idx) =>
+ baseSegments.every((seg, segIdx) => {
+ const target = fullSegments[idx + segIdx];
+ if (!target) {
+ return false;
+ }
+ if (seg.startsWith(':')) {
+ return true;
+ }
+ return target === seg;
+ })
+ );
+
+ if (startIndex >= 0) {
+ return fullSegments.slice(startIndex).join('/');
+ }
+ }
+
+ if (routeSegments.length <= fullSegments.length) {
+ return fullSegments.slice(fullSegments.length - routeSegments.length).join('/');
+ }
+
+ return fullSegments[fullSegments.length - 1] ?? trimmedPath;
+};
diff --git a/packages/react-router/src/ReactRouter/utils/pathNormalization.ts b/packages/react-router/src/ReactRouter/utils/pathNormalization.ts
new file mode 100644
index 00000000000..0ce180e4a18
--- /dev/null
+++ b/packages/react-router/src/ReactRouter/utils/pathNormalization.ts
@@ -0,0 +1,37 @@
+/**
+ * Ensures the given path has a leading slash.
+ *
+ * @param value The path string to normalize.
+ * @returns The path with a leading slash.
+ */
+export const ensureLeadingSlash = (value: string): string => {
+ if (value === '') {
+ return '/';
+ }
+ return value.startsWith('/') ? value : `/${value}`;
+};
+
+/**
+ * Strips the trailing slash from a path, unless it's the root path.
+ *
+ * @param value The path string to normalize.
+ * @returns The path without a trailing slash.
+ */
+export const stripTrailingSlash = (value: string): string => {
+ return value.length > 1 && value.endsWith('/') ? value.slice(0, -1) : value;
+};
+
+/**
+ * Normalizes a pathname for comparison by ensuring a leading slash
+ * and removing trailing slashes.
+ *
+ * @param value The pathname to normalize, can be undefined.
+ * @returns A normalized pathname string.
+ */
+export const normalizePathnameForComparison = (value: string | undefined): string => {
+ if (!value || value === '') {
+ return '/';
+ }
+ const withLeadingSlash = ensureLeadingSlash(value);
+ return stripTrailingSlash(withLeadingSlash);
+};
diff --git a/packages/react-router/src/ReactRouter/utils/routeElements.ts b/packages/react-router/src/ReactRouter/utils/routeElements.ts
new file mode 100644
index 00000000000..e0e52dd620f
--- /dev/null
+++ b/packages/react-router/src/ReactRouter/utils/routeElements.ts
@@ -0,0 +1,51 @@
+import React from 'react';
+import { Navigate, Route, Routes } from 'react-router-dom';
+
+/**
+ * Extracts the children from a Routes wrapper component.
+ * The use of `` is encouraged with React Router v6.
+ *
+ * @param node The React node to extract Routes children from.
+ * @returns The children of the Routes component, or undefined if not found.
+ */
+export const getRoutesChildren = (node: React.ReactNode): React.ReactNode | undefined => {
+ let routesNode: React.ReactNode;
+ React.Children.forEach(node as React.ReactElement, (child: React.ReactElement) => {
+ if (child.type === Routes) {
+ routesNode = child;
+ }
+ });
+
+ if (routesNode) {
+ // The children of the `` component are most likely
+ // (and should be) the `` components.
+ return (routesNode as React.ReactElement).props.children;
+ }
+ return undefined;
+};
+
+/**
+ * Extracts Route children from a node (either directly or from a Routes wrapper).
+ *
+ * @param children The children to extract routes from.
+ * @returns An array of Route elements.
+ */
+export const extractRouteChildren = (children: React.ReactNode): React.ReactElement[] => {
+ const routesChildren = getRoutesChildren(children) ?? children;
+ return React.Children.toArray(routesChildren).filter(
+ (child): child is React.ReactElement => React.isValidElement(child) && child.type === Route
+ );
+};
+
+/**
+ * Checks if a React element is a Navigate component (redirect).
+ *
+ * @param element The element to check.
+ * @returns True if the element is a Navigate component.
+ */
+export const isNavigateElement = (element: unknown): boolean => {
+ return (
+ React.isValidElement(element) &&
+ (element.type === Navigate || (typeof element.type === 'function' && element.type.name === 'Navigate'))
+ );
+};
diff --git a/packages/react-router/src/ReactRouter/utils/viewItemUtils.ts b/packages/react-router/src/ReactRouter/utils/viewItemUtils.ts
new file mode 100644
index 00000000000..c5d8b84d197
--- /dev/null
+++ b/packages/react-router/src/ReactRouter/utils/viewItemUtils.ts
@@ -0,0 +1,26 @@
+import type { ViewItem } from '@ionic/react';
+
+/**
+ * Sorts view items by route specificity (most specific first).
+ * - Exact matches (no wildcards/params) come first
+ * - Among wildcard routes, longer paths are more specific
+ *
+ * @param views The view items to sort.
+ * @returns A new sorted array of view items.
+ */
+export const sortViewsBySpecificity = (views: ViewItem[]): ViewItem[] => {
+ return [...views].sort((a, b) => {
+ const pathA = a.routeData?.childProps?.path || '';
+ const pathB = b.routeData?.childProps?.path || '';
+
+ // Exact matches (no wildcards/params) come first
+ const aHasWildcard = pathA.includes('*') || pathA.includes(':');
+ const bHasWildcard = pathB.includes('*') || pathB.includes(':');
+
+ if (!aHasWildcard && bHasWildcard) return -1;
+ if (aHasWildcard && !bHasWildcard) return 1;
+
+ // Among wildcard routes, longer paths are more specific
+ return pathB.length - pathA.length;
+ });
+};
diff --git a/packages/react-router/test/apps/reactrouter5/package-lock.json b/packages/react-router/test/apps/reactrouter6/package-lock.json
similarity index 99%
rename from packages/react-router/test/apps/reactrouter5/package-lock.json
rename to packages/react-router/test/apps/reactrouter6/package-lock.json
index 8783c314b8f..418033c77b3 100644
--- a/packages/react-router/test/apps/reactrouter5/package-lock.json
+++ b/packages/react-router/test/apps/reactrouter6/package-lock.json
@@ -8,8 +8,8 @@
"name": "react-router-new",
"version": "0.0.1",
"dependencies": {
- "@ionic/react": "^6.6.1",
- "@ionic/react-router": "^6.6.1",
+ "@ionic/react": "^8.6.1",
+ "@ionic/react-router": "^8.6.1",
"@testing-library/jest-dom": "^5.16.5",
"@testing-library/react": "^14.0.0",
"@testing-library/user-event": "^14.4.3",
@@ -17,11 +17,11 @@
"@types/react-dom": "^18.0.11",
"@types/react-router": "^5.1.20",
"@types/react-router-dom": "^5.3.3",
- "ionicons": "^8.0.13",
+ "ionicons": "^6.0.4",
"react": "^18.2.0",
"react-dom": "^18.2.0",
- "react-router": "^5.3.4",
- "react-router-dom": "^5.3.4",
+ "react-router": "^6.0.0",
+ "react-router-dom": "^6.0.0",
"react-scripts": "^5.0.1",
"sass-loader": "8.0.2",
"typescript": "^4.4.2",
@@ -2295,31 +2295,52 @@
"integrity": "sha512-ZnQMnLV4e7hDlUvw8H+U8ASL02SS2Gn6+9Ac3wGGLIe7+je2AeAOxPY+izIPJDfFDb7eDjev0Us8MO1iFRN8hA=="
},
"node_modules/@ionic/core": {
- "version": "6.6.1",
- "resolved": "https://registry.npmjs.org/@ionic/core/-/core-6.6.1.tgz",
- "integrity": "sha512-+LMBk7kUX55rvYQ35AiAXPNzbNm3zNx9ginvuCzByguMjl+N63lpdPzIEfeRURkmq7NByD1VqpodMj5c6Oq2KQ==",
+ "version": "8.6.2",
+ "resolved": "https://registry.npmjs.org/@ionic/core/-/core-8.6.2.tgz",
+ "integrity": "sha512-CGZ9CDp/XHtm9WrK3wt0ZtR2f2B76qEvJIaF/juCqmpza9Al6u2L9R/NTEwInDRCWfbkAIF22nHNH54/VvN78Q==",
"dependencies": {
- "@stencil/core": "^2.18.0",
- "ionicons": "^6.1.3",
+ "@stencil/core": "4.33.1",
+ "ionicons": "^7.2.2",
"tslib": "^2.1.0"
}
},
+ "node_modules/@ionic/core/node_modules/@stencil/core": {
+ "version": "4.33.1",
+ "resolved": "https://registry.npmjs.org/@stencil/core/-/core-4.33.1.tgz",
+ "integrity": "sha512-12k9xhAJBkpg598it+NRmaYIdEe6TSnsL/v6/KRXDcUyTK11VYwZQej2eHnMWtqot+znJ+GNTqb5YbiXi+5Low==",
+ "bin": {
+ "stencil": "bin/stencil"
+ },
+ "engines": {
+ "node": ">=16.0.0",
+ "npm": ">=7.10.0"
+ },
+ "optionalDependencies": {
+ "@rollup/rollup-darwin-arm64": "4.34.9",
+ "@rollup/rollup-darwin-x64": "4.34.9",
+ "@rollup/rollup-linux-arm64-gnu": "4.34.9",
+ "@rollup/rollup-linux-arm64-musl": "4.34.9",
+ "@rollup/rollup-linux-x64-gnu": "4.34.9",
+ "@rollup/rollup-linux-x64-musl": "4.34.9",
+ "@rollup/rollup-win32-arm64-msvc": "4.34.9",
+ "@rollup/rollup-win32-x64-msvc": "4.34.9"
+ }
+ },
"node_modules/@ionic/core/node_modules/ionicons": {
- "version": "6.1.3",
- "resolved": "https://registry.npmjs.org/ionicons/-/ionicons-6.1.3.tgz",
- "integrity": "sha512-ptzz38dd/Yq+PgjhXegh7yhb/SLIk1bvL9vQDtLv1aoSc7alO6mX2DIMgcKYzt9vrNWkRu1f9Jr78zIFFyOXqw==",
- "license": "MIT",
+ "version": "7.4.0",
+ "resolved": "https://registry.npmjs.org/ionicons/-/ionicons-7.4.0.tgz",
+ "integrity": "sha512-ZK94MMqgzMCPPMhmk8Ouu6goyVHFIlw/ACP6oe3FrikcI0N7CX0xcwVaEbUc0G/v3W0shI93vo+9ve/KpvcNhQ==",
"dependencies": {
- "@stencil/core": "^2.18.0"
+ "@stencil/core": "^4.0.3"
}
},
"node_modules/@ionic/react": {
- "version": "6.6.1",
- "resolved": "https://registry.npmjs.org/@ionic/react/-/react-6.6.1.tgz",
- "integrity": "sha512-gq8FzC0CAPt6MpOFethe9+zIU7jg1JyWPWRANJ/UudlF05f2eFOzLgqe/EH0uIIsuDjeoM50hrqfuvg6x2j3UQ==",
+ "version": "8.6.2",
+ "resolved": "https://registry.npmjs.org/@ionic/react/-/react-8.6.2.tgz",
+ "integrity": "sha512-SXE1RnzGqj0MGKGs6D4UCk4rOghbLYI5qwANdZJuBxlIcrcBJuAySjneuTGt+Y3UHS8W3YZHFujRv2Gvb+zvqQ==",
"dependencies": {
- "@ionic/core": "6.6.1",
- "ionicons": "^6.1.3",
+ "@ionic/core": "8.6.2",
+ "ionicons": "^7.0.0",
"tslib": "*"
},
"peerDependencies": {
@@ -2328,11 +2349,11 @@
}
},
"node_modules/@ionic/react-router": {
- "version": "6.6.1",
- "resolved": "https://registry.npmjs.org/@ionic/react-router/-/react-router-6.6.1.tgz",
- "integrity": "sha512-9bHlz3MdzvkUyZ9QfxzcAGDtbRhZ7R5uMjm3UHvGhYS1Rdx4KIc8E5q31IQf7H6j2ULU9YcB7UeyW5ORxBX18Q==",
+ "version": "8.6.2",
+ "resolved": "https://registry.npmjs.org/@ionic/react-router/-/react-router-8.6.2.tgz",
+ "integrity": "sha512-wNVYZHEHkRkNimiK24bJ8KsWjuQyug7C+J/rNER7BKtZDzU3kWKVjvzD3P7kaiOf/DtVo+OrZNvYQJOuoIEhWg==",
"dependencies": {
- "@ionic/react": "6.6.1",
+ "@ionic/react": "8.6.2",
"tslib": "*"
},
"peerDependencies": {
@@ -2342,13 +2363,34 @@
"react-router-dom": "^5.0.1"
}
},
+ "node_modules/@ionic/react/node_modules/@stencil/core": {
+ "version": "4.35.1",
+ "resolved": "https://registry.npmjs.org/@stencil/core/-/core-4.35.1.tgz",
+ "integrity": "sha512-u65m3TbzOtpn679gUV4Yvi8YpInhRJ62js30a7YtXief9Ej/vzrhwDE22U0w4DMWJOYwAsJl133BUaZkWwnmzg==",
+ "bin": {
+ "stencil": "bin/stencil"
+ },
+ "engines": {
+ "node": ">=16.0.0",
+ "npm": ">=7.10.0"
+ },
+ "optionalDependencies": {
+ "@rollup/rollup-darwin-arm64": "4.34.9",
+ "@rollup/rollup-darwin-x64": "4.34.9",
+ "@rollup/rollup-linux-arm64-gnu": "4.34.9",
+ "@rollup/rollup-linux-arm64-musl": "4.34.9",
+ "@rollup/rollup-linux-x64-gnu": "4.34.9",
+ "@rollup/rollup-linux-x64-musl": "4.34.9",
+ "@rollup/rollup-win32-arm64-msvc": "4.34.9",
+ "@rollup/rollup-win32-x64-msvc": "4.34.9"
+ }
+ },
"node_modules/@ionic/react/node_modules/ionicons": {
- "version": "6.1.3",
- "resolved": "https://registry.npmjs.org/ionicons/-/ionicons-6.1.3.tgz",
- "integrity": "sha512-ptzz38dd/Yq+PgjhXegh7yhb/SLIk1bvL9vQDtLv1aoSc7alO6mX2DIMgcKYzt9vrNWkRu1f9Jr78zIFFyOXqw==",
- "license": "MIT",
+ "version": "7.4.0",
+ "resolved": "https://registry.npmjs.org/ionicons/-/ionicons-7.4.0.tgz",
+ "integrity": "sha512-ZK94MMqgzMCPPMhmk8Ouu6goyVHFIlw/ACP6oe3FrikcI0N7CX0xcwVaEbUc0G/v3W0shI93vo+9ve/KpvcNhQ==",
"dependencies": {
- "@stencil/core": "^2.18.0"
+ "@stencil/core": "^4.0.3"
}
},
"node_modules/@istanbuljs/load-nyc-config": {
@@ -3703,6 +3745,14 @@
"node": ">= 8"
}
},
+ "node_modules/@remix-run/router": {
+ "version": "1.23.0",
+ "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.0.tgz",
+ "integrity": "sha512-O3rHJzAQKamUz1fvE0Qaw0xSFqsA/yafi2iqeE0pvdFtCO1viYx8QL6f3Ln/aCCTLxs68SLf0KPM9eSeM8yBnA==",
+ "engines": {
+ "node": ">=14.0.0"
+ }
+ },
"node_modules/@rollup/plugin-babel": {
"version": "5.3.1",
"resolved": "https://registry.npmjs.org/@rollup/plugin-babel/-/plugin-babel-5.3.1.tgz",
@@ -3800,7 +3850,6 @@
"cpu": [
"arm64"
],
- "license": "MIT",
"optional": true,
"os": [
"darwin"
@@ -3813,7 +3862,6 @@
"cpu": [
"x64"
],
- "license": "MIT",
"optional": true,
"os": [
"darwin"
@@ -3826,7 +3874,6 @@
"cpu": [
"arm64"
],
- "license": "MIT",
"optional": true,
"os": [
"linux"
@@ -3839,7 +3886,6 @@
"cpu": [
"arm64"
],
- "license": "MIT",
"optional": true,
"os": [
"linux"
@@ -3852,7 +3898,6 @@
"cpu": [
"x64"
],
- "license": "MIT",
"optional": true,
"os": [
"linux"
@@ -3865,7 +3910,6 @@
"cpu": [
"x64"
],
- "license": "MIT",
"optional": true,
"os": [
"linux"
@@ -3878,7 +3922,6 @@
"cpu": [
"arm64"
],
- "license": "MIT",
"optional": true,
"os": [
"win32"
@@ -3891,7 +3934,6 @@
"cpu": [
"x64"
],
- "license": "MIT",
"optional": true,
"os": [
"win32"
@@ -10771,27 +10813,6 @@
"he": "bin/he"
}
},
- "node_modules/history": {
- "version": "4.10.1",
- "resolved": "https://registry.npmjs.org/history/-/history-4.10.1.tgz",
- "integrity": "sha512-36nwAD620w12kuzPAsyINPWJqlNbij+hpK1k9XRloDtym8mxzGYl2c17LnV6IAGB2Dmg4tEa7G7DlawS0+qjew==",
- "dependencies": {
- "@babel/runtime": "^7.1.2",
- "loose-envify": "^1.2.0",
- "resolve-pathname": "^3.0.0",
- "tiny-invariant": "^1.0.2",
- "tiny-warning": "^1.0.0",
- "value-equal": "^1.0.1"
- }
- },
- "node_modules/hoist-non-react-statics": {
- "version": "3.3.2",
- "resolved": "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz",
- "integrity": "sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==",
- "dependencies": {
- "react-is": "^16.7.0"
- }
- },
"node_modules/hoopy": {
"version": "0.1.4",
"resolved": "https://registry.npmjs.org/hoopy/-/hoopy-0.1.4.tgz",
@@ -11216,35 +11237,11 @@
}
},
"node_modules/ionicons": {
- "version": "8.0.13",
- "resolved": "https://registry.npmjs.org/ionicons/-/ionicons-8.0.13.tgz",
- "integrity": "sha512-2QQVyG2P4wszne79jemMjWYLp0DBbDhr4/yFroPCxvPP1wtMxgdIV3l5n+XZ5E9mgoXU79w7yTWpm2XzJsISxQ==",
- "license": "MIT",
+ "version": "6.1.3",
+ "resolved": "https://registry.npmjs.org/ionicons/-/ionicons-6.1.3.tgz",
+ "integrity": "sha512-ptzz38dd/Yq+PgjhXegh7yhb/SLIk1bvL9vQDtLv1aoSc7alO6mX2DIMgcKYzt9vrNWkRu1f9Jr78zIFFyOXqw==",
"dependencies": {
- "@stencil/core": "^4.35.3"
- }
- },
- "node_modules/ionicons/node_modules/@stencil/core": {
- "version": "4.35.3",
- "resolved": "https://registry.npmjs.org/@stencil/core/-/core-4.35.3.tgz",
- "integrity": "sha512-RH5/I+amV31QI8TMXhXkAkjzs2eod6Y07jkUYTl9kMB+X7c5wUpv95Y/2LtcAx0Rqdhh4SHbJiwpr0ApBZmv0g==",
- "license": "MIT",
- "bin": {
- "stencil": "bin/stencil"
- },
- "engines": {
- "node": ">=16.0.0",
- "npm": ">=7.10.0"
- },
- "optionalDependencies": {
- "@rollup/rollup-darwin-arm64": "4.34.9",
- "@rollup/rollup-darwin-x64": "4.34.9",
- "@rollup/rollup-linux-arm64-gnu": "4.34.9",
- "@rollup/rollup-linux-arm64-musl": "4.34.9",
- "@rollup/rollup-linux-x64-gnu": "4.34.9",
- "@rollup/rollup-linux-x64-musl": "4.34.9",
- "@rollup/rollup-win32-arm64-msvc": "4.34.9",
- "@rollup/rollup-win32-x64-msvc": "4.34.9"
+ "@stencil/core": "^2.18.0"
}
},
"node_modules/ipaddr.js": {
@@ -11695,11 +11692,6 @@
"node": ">=8"
}
},
- "node_modules/isarray": {
- "version": "0.0.1",
- "resolved": "https://registry.npmjs.org/isarray/-/isarray-0.0.1.tgz",
- "integrity": "sha512-D2S+3GLxWH+uhrNEcoh/fnmYeP8E8/zHl644d/jdA0g2uyXvy3sb0qxotE+ne0LtccHknQzWwZEzhak7oJ0COQ=="
- },
"node_modules/isexe": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
@@ -16428,14 +16420,6 @@
"resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz",
"integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw=="
},
- "node_modules/path-to-regexp": {
- "version": "1.8.0",
- "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-1.8.0.tgz",
- "integrity": "sha512-n43JRhlUKUAlibEJhPeir1ncUID16QnEjNpwzNdO3Lm4ywrBpBZ5oLD0I6br9evr1Y9JTqwRtAh7JLoOzAQdVA==",
- "dependencies": {
- "isarray": "0.0.1"
- }
- },
"node_modules/pend": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz",
@@ -18388,39 +18372,33 @@
}
},
"node_modules/react-router": {
- "version": "5.3.4",
- "resolved": "https://registry.npmjs.org/react-router/-/react-router-5.3.4.tgz",
- "integrity": "sha512-Ys9K+ppnJah3QuaRiLxk+jDWOR1MekYQrlytiXxC1RyfbdsZkS5pvKAzCCr031xHixZwpnsYNT5xysdFHQaYsA==",
+ "version": "6.30.1",
+ "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.30.1.tgz",
+ "integrity": "sha512-X1m21aEmxGXqENEPG3T6u0Th7g0aS4ZmoNynhbs+Cn+q+QGTLt+d5IQ2bHAXKzKcxGJjxACpVbnYQSCRcfxHlQ==",
"dependencies": {
- "@babel/runtime": "^7.12.13",
- "history": "^4.9.0",
- "hoist-non-react-statics": "^3.1.0",
- "loose-envify": "^1.3.1",
- "path-to-regexp": "^1.7.0",
- "prop-types": "^15.6.2",
- "react-is": "^16.6.0",
- "tiny-invariant": "^1.0.2",
- "tiny-warning": "^1.0.0"
+ "@remix-run/router": "1.23.0"
+ },
+ "engines": {
+ "node": ">=14.0.0"
},
"peerDependencies": {
- "react": ">=15"
+ "react": ">=16.8"
}
},
"node_modules/react-router-dom": {
- "version": "5.3.4",
- "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-5.3.4.tgz",
- "integrity": "sha512-m4EqFMHv/Ih4kpcBCONHbkT68KoAeHN4p3lAGoNryfHi0dMy0kCzEZakiKRsvg5wHZ/JLrLW8o8KomWiz/qbYQ==",
+ "version": "6.30.1",
+ "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.30.1.tgz",
+ "integrity": "sha512-llKsgOkZdbPU1Eg3zK8lCn+sjD9wMRZZPuzmdWWX5SUs8OFkN5HnFVC0u5KMeMaC9aoancFI/KoLuKPqN+hxHw==",
"dependencies": {
- "@babel/runtime": "^7.12.13",
- "history": "^4.9.0",
- "loose-envify": "^1.3.1",
- "prop-types": "^15.6.2",
- "react-router": "5.3.4",
- "tiny-invariant": "^1.0.2",
- "tiny-warning": "^1.0.0"
+ "@remix-run/router": "1.23.0",
+ "react-router": "6.30.1"
+ },
+ "engines": {
+ "node": ">=14.0.0"
},
"peerDependencies": {
- "react": ">=15"
+ "react": ">=16.8",
+ "react-dom": ">=16.8"
}
},
"node_modules/react-scripts": {
@@ -19372,11 +19350,6 @@
"node": ">=8"
}
},
- "node_modules/resolve-pathname": {
- "version": "3.0.0",
- "resolved": "https://registry.npmjs.org/resolve-pathname/-/resolve-pathname-3.0.0.tgz",
- "integrity": "sha512-C7rARubxI8bXFNB/hqcp/4iUeIXJhJZvFPFPiSPRnhU5UPxzMFIl+2E6yY6c4k9giDJAhtV+enfA+G89N6Csng=="
- },
"node_modules/resolve.exports": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/resolve.exports/-/resolve.exports-1.1.0.tgz",
@@ -20817,16 +20790,6 @@
"resolved": "https://registry.npmjs.org/thunky/-/thunky-1.1.0.tgz",
"integrity": "sha512-eHY7nBftgThBqOyHGVN+l8gF0BucP09fMo0oO/Lb0w1OF80dJv+lDVpXG60WMQvkcxAkNybKsrEIE3ZtKGmPrA=="
},
- "node_modules/tiny-invariant": {
- "version": "1.2.0",
- "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.2.0.tgz",
- "integrity": "sha512-1Uhn/aqw5C6RI4KejVeTg6mIS7IqxnLJ8Mv2tV5rTc0qWobay7pDUz6Wi392Cnc8ak1H0F2cjoRzb2/AW4+Fvg=="
- },
- "node_modules/tiny-warning": {
- "version": "1.0.3",
- "resolved": "https://registry.npmjs.org/tiny-warning/-/tiny-warning-1.0.3.tgz",
- "integrity": "sha512-lBN9zLN/oAf68o3zNXYrdCt1kP8WsiGW8Oo2ka41b2IM5JL/S1CTyX1rW0mb/zSuJun0ZUrDxx4sqvYS2FWzPA=="
- },
"node_modules/tmp": {
"version": "0.2.1",
"resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.1.tgz",
@@ -21264,11 +21227,6 @@
"node": ">= 8"
}
},
- "node_modules/value-equal": {
- "version": "1.0.1",
- "resolved": "https://registry.npmjs.org/value-equal/-/value-equal-1.0.1.tgz",
- "integrity": "sha512-NOJ6JZCAWr0zlxZt+xqCHNTEKOsrks2HQd4MqhP1qy4z1SkbEP467eNx6TgDKXMvUOb+OENfJCZwM+16n7fRfw=="
- },
"node_modules/vary": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
@@ -24028,51 +23986,81 @@
"integrity": "sha512-ZnQMnLV4e7hDlUvw8H+U8ASL02SS2Gn6+9Ac3wGGLIe7+je2AeAOxPY+izIPJDfFDb7eDjev0Us8MO1iFRN8hA=="
},
"@ionic/core": {
- "version": "6.6.1",
- "resolved": "https://registry.npmjs.org/@ionic/core/-/core-6.6.1.tgz",
- "integrity": "sha512-+LMBk7kUX55rvYQ35AiAXPNzbNm3zNx9ginvuCzByguMjl+N63lpdPzIEfeRURkmq7NByD1VqpodMj5c6Oq2KQ==",
+ "version": "8.6.2",
+ "resolved": "https://registry.npmjs.org/@ionic/core/-/core-8.6.2.tgz",
+ "integrity": "sha512-CGZ9CDp/XHtm9WrK3wt0ZtR2f2B76qEvJIaF/juCqmpza9Al6u2L9R/NTEwInDRCWfbkAIF22nHNH54/VvN78Q==",
"requires": {
- "@stencil/core": "^2.18.0",
- "ionicons": "^6.1.3",
+ "@stencil/core": "4.33.1",
+ "ionicons": "^7.2.2",
"tslib": "^2.1.0"
},
"dependencies": {
+ "@stencil/core": {
+ "version": "4.33.1",
+ "resolved": "https://registry.npmjs.org/@stencil/core/-/core-4.33.1.tgz",
+ "integrity": "sha512-12k9xhAJBkpg598it+NRmaYIdEe6TSnsL/v6/KRXDcUyTK11VYwZQej2eHnMWtqot+znJ+GNTqb5YbiXi+5Low==",
+ "requires": {
+ "@rollup/rollup-darwin-arm64": "4.34.9",
+ "@rollup/rollup-darwin-x64": "4.34.9",
+ "@rollup/rollup-linux-arm64-gnu": "4.34.9",
+ "@rollup/rollup-linux-arm64-musl": "4.34.9",
+ "@rollup/rollup-linux-x64-gnu": "4.34.9",
+ "@rollup/rollup-linux-x64-musl": "4.34.9",
+ "@rollup/rollup-win32-arm64-msvc": "4.34.9",
+ "@rollup/rollup-win32-x64-msvc": "4.34.9"
+ }
+ },
"ionicons": {
- "version": "6.1.3",
- "resolved": "https://registry.npmjs.org/ionicons/-/ionicons-6.1.3.tgz",
- "integrity": "sha512-ptzz38dd/Yq+PgjhXegh7yhb/SLIk1bvL9vQDtLv1aoSc7alO6mX2DIMgcKYzt9vrNWkRu1f9Jr78zIFFyOXqw==",
+ "version": "7.4.0",
+ "resolved": "https://registry.npmjs.org/ionicons/-/ionicons-7.4.0.tgz",
+ "integrity": "sha512-ZK94MMqgzMCPPMhmk8Ouu6goyVHFIlw/ACP6oe3FrikcI0N7CX0xcwVaEbUc0G/v3W0shI93vo+9ve/KpvcNhQ==",
"requires": {
- "@stencil/core": "^2.18.0"
+ "@stencil/core": "^4.0.3"
}
}
}
},
"@ionic/react": {
- "version": "6.6.1",
- "resolved": "https://registry.npmjs.org/@ionic/react/-/react-6.6.1.tgz",
- "integrity": "sha512-gq8FzC0CAPt6MpOFethe9+zIU7jg1JyWPWRANJ/UudlF05f2eFOzLgqe/EH0uIIsuDjeoM50hrqfuvg6x2j3UQ==",
+ "version": "8.6.2",
+ "resolved": "https://registry.npmjs.org/@ionic/react/-/react-8.6.2.tgz",
+ "integrity": "sha512-SXE1RnzGqj0MGKGs6D4UCk4rOghbLYI5qwANdZJuBxlIcrcBJuAySjneuTGt+Y3UHS8W3YZHFujRv2Gvb+zvqQ==",
"requires": {
- "@ionic/core": "6.6.1",
- "ionicons": "^6.1.3",
+ "@ionic/core": "8.6.2",
+ "ionicons": "^7.0.0",
"tslib": "*"
},
"dependencies": {
+ "@stencil/core": {
+ "version": "4.35.1",
+ "resolved": "https://registry.npmjs.org/@stencil/core/-/core-4.35.1.tgz",
+ "integrity": "sha512-u65m3TbzOtpn679gUV4Yvi8YpInhRJ62js30a7YtXief9Ej/vzrhwDE22U0w4DMWJOYwAsJl133BUaZkWwnmzg==",
+ "requires": {
+ "@rollup/rollup-darwin-arm64": "4.34.9",
+ "@rollup/rollup-darwin-x64": "4.34.9",
+ "@rollup/rollup-linux-arm64-gnu": "4.34.9",
+ "@rollup/rollup-linux-arm64-musl": "4.34.9",
+ "@rollup/rollup-linux-x64-gnu": "4.34.9",
+ "@rollup/rollup-linux-x64-musl": "4.34.9",
+ "@rollup/rollup-win32-arm64-msvc": "4.34.9",
+ "@rollup/rollup-win32-x64-msvc": "4.34.9"
+ }
+ },
"ionicons": {
- "version": "6.1.3",
- "resolved": "https://registry.npmjs.org/ionicons/-/ionicons-6.1.3.tgz",
- "integrity": "sha512-ptzz38dd/Yq+PgjhXegh7yhb/SLIk1bvL9vQDtLv1aoSc7alO6mX2DIMgcKYzt9vrNWkRu1f9Jr78zIFFyOXqw==",
+ "version": "7.4.0",
+ "resolved": "https://registry.npmjs.org/ionicons/-/ionicons-7.4.0.tgz",
+ "integrity": "sha512-ZK94MMqgzMCPPMhmk8Ouu6goyVHFIlw/ACP6oe3FrikcI0N7CX0xcwVaEbUc0G/v3W0shI93vo+9ve/KpvcNhQ==",
"requires": {
- "@stencil/core": "^2.18.0"
+ "@stencil/core": "^4.0.3"
}
}
}
},
"@ionic/react-router": {
- "version": "6.6.1",
- "resolved": "https://registry.npmjs.org/@ionic/react-router/-/react-router-6.6.1.tgz",
- "integrity": "sha512-9bHlz3MdzvkUyZ9QfxzcAGDtbRhZ7R5uMjm3UHvGhYS1Rdx4KIc8E5q31IQf7H6j2ULU9YcB7UeyW5ORxBX18Q==",
+ "version": "8.6.2",
+ "resolved": "https://registry.npmjs.org/@ionic/react-router/-/react-router-8.6.2.tgz",
+ "integrity": "sha512-wNVYZHEHkRkNimiK24bJ8KsWjuQyug7C+J/rNER7BKtZDzU3kWKVjvzD3P7kaiOf/DtVo+OrZNvYQJOuoIEhWg==",
"requires": {
- "@ionic/react": "6.6.1",
+ "@ionic/react": "8.6.2",
"tslib": "*"
}
},
@@ -25068,6 +25056,11 @@
}
}
},
+ "@remix-run/router": {
+ "version": "1.23.0",
+ "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.0.tgz",
+ "integrity": "sha512-O3rHJzAQKamUz1fvE0Qaw0xSFqsA/yafi2iqeE0pvdFtCO1viYx8QL6f3Ln/aCCTLxs68SLf0KPM9eSeM8yBnA=="
+ },
"@rollup/plugin-babel": {
"version": "5.3.1",
"resolved": "https://registry.npmjs.org/@rollup/plugin-babel/-/plugin-babel-5.3.1.tgz",
@@ -30255,27 +30248,6 @@
"resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz",
"integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw=="
},
- "history": {
- "version": "4.10.1",
- "resolved": "https://registry.npmjs.org/history/-/history-4.10.1.tgz",
- "integrity": "sha512-36nwAD620w12kuzPAsyINPWJqlNbij+hpK1k9XRloDtym8mxzGYl2c17LnV6IAGB2Dmg4tEa7G7DlawS0+qjew==",
- "requires": {
- "@babel/runtime": "^7.1.2",
- "loose-envify": "^1.2.0",
- "resolve-pathname": "^3.0.0",
- "tiny-invariant": "^1.0.2",
- "tiny-warning": "^1.0.0",
- "value-equal": "^1.0.1"
- }
- },
- "hoist-non-react-statics": {
- "version": "3.3.2",
- "resolved": "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz",
- "integrity": "sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==",
- "requires": {
- "react-is": "^16.7.0"
- }
- },
"hoopy": {
"version": "0.1.4",
"resolved": "https://registry.npmjs.org/hoopy/-/hoopy-0.1.4.tgz",
@@ -30582,28 +30554,11 @@
"dev": true
},
"ionicons": {
- "version": "8.0.13",
- "resolved": "https://registry.npmjs.org/ionicons/-/ionicons-8.0.13.tgz",
- "integrity": "sha512-2QQVyG2P4wszne79jemMjWYLp0DBbDhr4/yFroPCxvPP1wtMxgdIV3l5n+XZ5E9mgoXU79w7yTWpm2XzJsISxQ==",
+ "version": "6.1.3",
+ "resolved": "https://registry.npmjs.org/ionicons/-/ionicons-6.1.3.tgz",
+ "integrity": "sha512-ptzz38dd/Yq+PgjhXegh7yhb/SLIk1bvL9vQDtLv1aoSc7alO6mX2DIMgcKYzt9vrNWkRu1f9Jr78zIFFyOXqw==",
"requires": {
- "@stencil/core": "^4.35.3"
- },
- "dependencies": {
- "@stencil/core": {
- "version": "4.35.3",
- "resolved": "https://registry.npmjs.org/@stencil/core/-/core-4.35.3.tgz",
- "integrity": "sha512-RH5/I+amV31QI8TMXhXkAkjzs2eod6Y07jkUYTl9kMB+X7c5wUpv95Y/2LtcAx0Rqdhh4SHbJiwpr0ApBZmv0g==",
- "requires": {
- "@rollup/rollup-darwin-arm64": "4.34.9",
- "@rollup/rollup-darwin-x64": "4.34.9",
- "@rollup/rollup-linux-arm64-gnu": "4.34.9",
- "@rollup/rollup-linux-arm64-musl": "4.34.9",
- "@rollup/rollup-linux-x64-gnu": "4.34.9",
- "@rollup/rollup-linux-x64-musl": "4.34.9",
- "@rollup/rollup-win32-arm64-msvc": "4.34.9",
- "@rollup/rollup-win32-x64-msvc": "4.34.9"
- }
- }
+ "@stencil/core": "^2.18.0"
}
},
"ipaddr.js": {
@@ -30892,11 +30847,6 @@
"is-docker": "^2.0.0"
}
},
- "isarray": {
- "version": "0.0.1",
- "resolved": "https://registry.npmjs.org/isarray/-/isarray-0.0.1.tgz",
- "integrity": "sha512-D2S+3GLxWH+uhrNEcoh/fnmYeP8E8/zHl644d/jdA0g2uyXvy3sb0qxotE+ne0LtccHknQzWwZEzhak7oJ0COQ=="
- },
"isexe": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
@@ -34400,14 +34350,6 @@
"resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz",
"integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw=="
},
- "path-to-regexp": {
- "version": "1.8.0",
- "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-1.8.0.tgz",
- "integrity": "sha512-n43JRhlUKUAlibEJhPeir1ncUID16QnEjNpwzNdO3Lm4ywrBpBZ5oLD0I6br9evr1Y9JTqwRtAh7JLoOzAQdVA==",
- "requires": {
- "isarray": "0.0.1"
- }
- },
"pend": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz",
@@ -35656,33 +35598,20 @@
"integrity": "sha512-F27qZr8uUqwhWZboondsPx8tnC3Ct3SxZA3V5WyEvujRyyNv0VYPhoBg1gZ8/MV5tubQp76Trw8lTv9hzRBa+A=="
},
"react-router": {
- "version": "5.3.4",
- "resolved": "https://registry.npmjs.org/react-router/-/react-router-5.3.4.tgz",
- "integrity": "sha512-Ys9K+ppnJah3QuaRiLxk+jDWOR1MekYQrlytiXxC1RyfbdsZkS5pvKAzCCr031xHixZwpnsYNT5xysdFHQaYsA==",
+ "version": "6.30.1",
+ "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.30.1.tgz",
+ "integrity": "sha512-X1m21aEmxGXqENEPG3T6u0Th7g0aS4ZmoNynhbs+Cn+q+QGTLt+d5IQ2bHAXKzKcxGJjxACpVbnYQSCRcfxHlQ==",
"requires": {
- "@babel/runtime": "^7.12.13",
- "history": "^4.9.0",
- "hoist-non-react-statics": "^3.1.0",
- "loose-envify": "^1.3.1",
- "path-to-regexp": "^1.7.0",
- "prop-types": "^15.6.2",
- "react-is": "^16.6.0",
- "tiny-invariant": "^1.0.2",
- "tiny-warning": "^1.0.0"
+ "@remix-run/router": "1.23.0"
}
},
"react-router-dom": {
- "version": "5.3.4",
- "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-5.3.4.tgz",
- "integrity": "sha512-m4EqFMHv/Ih4kpcBCONHbkT68KoAeHN4p3lAGoNryfHi0dMy0kCzEZakiKRsvg5wHZ/JLrLW8o8KomWiz/qbYQ==",
+ "version": "6.30.1",
+ "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.30.1.tgz",
+ "integrity": "sha512-llKsgOkZdbPU1Eg3zK8lCn+sjD9wMRZZPuzmdWWX5SUs8OFkN5HnFVC0u5KMeMaC9aoancFI/KoLuKPqN+hxHw==",
"requires": {
- "@babel/runtime": "^7.12.13",
- "history": "^4.9.0",
- "loose-envify": "^1.3.1",
- "prop-types": "^15.6.2",
- "react-router": "5.3.4",
- "tiny-invariant": "^1.0.2",
- "tiny-warning": "^1.0.0"
+ "@remix-run/router": "1.23.0",
+ "react-router": "6.30.1"
}
},
"react-scripts": {
@@ -36303,11 +36232,6 @@
}
}
},
- "resolve-pathname": {
- "version": "3.0.0",
- "resolved": "https://registry.npmjs.org/resolve-pathname/-/resolve-pathname-3.0.0.tgz",
- "integrity": "sha512-C7rARubxI8bXFNB/hqcp/4iUeIXJhJZvFPFPiSPRnhU5UPxzMFIl+2E6yY6c4k9giDJAhtV+enfA+G89N6Csng=="
- },
"resolve.exports": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/resolve.exports/-/resolve.exports-1.1.0.tgz",
@@ -37413,16 +37337,6 @@
"resolved": "https://registry.npmjs.org/thunky/-/thunky-1.1.0.tgz",
"integrity": "sha512-eHY7nBftgThBqOyHGVN+l8gF0BucP09fMo0oO/Lb0w1OF80dJv+lDVpXG60WMQvkcxAkNybKsrEIE3ZtKGmPrA=="
},
- "tiny-invariant": {
- "version": "1.2.0",
- "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.2.0.tgz",
- "integrity": "sha512-1Uhn/aqw5C6RI4KejVeTg6mIS7IqxnLJ8Mv2tV5rTc0qWobay7pDUz6Wi392Cnc8ak1H0F2cjoRzb2/AW4+Fvg=="
- },
- "tiny-warning": {
- "version": "1.0.3",
- "resolved": "https://registry.npmjs.org/tiny-warning/-/tiny-warning-1.0.3.tgz",
- "integrity": "sha512-lBN9zLN/oAf68o3zNXYrdCt1kP8WsiGW8Oo2ka41b2IM5JL/S1CTyX1rW0mb/zSuJun0ZUrDxx4sqvYS2FWzPA=="
- },
"tmp": {
"version": "0.2.1",
"resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.1.tgz",
@@ -37759,11 +37673,6 @@
}
}
},
- "value-equal": {
- "version": "1.0.1",
- "resolved": "https://registry.npmjs.org/value-equal/-/value-equal-1.0.1.tgz",
- "integrity": "sha512-NOJ6JZCAWr0zlxZt+xqCHNTEKOsrks2HQd4MqhP1qy4z1SkbEP467eNx6TgDKXMvUOb+OENfJCZwM+16n7fRfw=="
- },
"vary": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
diff --git a/packages/react-router/test/apps/reactrouter5/package.json b/packages/react-router/test/apps/reactrouter6/package.json
similarity index 80%
rename from packages/react-router/test/apps/reactrouter5/package.json
rename to packages/react-router/test/apps/reactrouter6/package.json
index 4e59232f653..5d2fe05ebf2 100644
--- a/packages/react-router/test/apps/reactrouter5/package.json
+++ b/packages/react-router/test/apps/reactrouter6/package.json
@@ -2,21 +2,25 @@
"name": "react-router-new",
"version": "0.0.1",
"private": true,
+ "overrides": {
+ "@ionic/react-router": {
+ "react-router": "$react-router",
+ "react-router-dom": "$react-router-dom"
+ }
+ },
"dependencies": {
- "@ionic/react": "^6.6.1",
- "@ionic/react-router": "^6.6.1",
+ "@ionic/react": "^8.6.1",
+ "@ionic/react-router": "^8.6.1",
"@testing-library/jest-dom": "^5.16.5",
"@testing-library/react": "^14.0.0",
"@testing-library/user-event": "^14.4.3",
"@types/react": "^18.0.28",
"@types/react-dom": "^18.0.11",
- "@types/react-router": "^5.1.20",
- "@types/react-router-dom": "^5.3.3",
- "ionicons": "^8.0.13",
+ "ionicons": "^6.0.4",
"react": "^18.2.0",
"react-dom": "^18.2.0",
- "react-router": "^5.3.4",
- "react-router-dom": "^5.3.4",
+ "react-router": "^6.0.0",
+ "react-router-dom": "^6.0.0",
"react-scripts": "^5.0.1",
"sass-loader": "8.0.2",
"typescript": "^4.4.2",
@@ -52,6 +56,7 @@
"devDependencies": {
"concurrently": "^6.3.0",
"cypress": "^13.2.0",
+ "cypress-terminal-report": "^5.3.0",
"serve": "^14.0.1",
"wait-on": "^6.0.0",
"webpack-cli": "^4.9.1"
diff --git a/packages/react-router/test/base/scripts/sync.sh b/packages/react-router/test/base/scripts/sync.sh
index 1be037080bd..ad0d879c771 100644
--- a/packages/react-router/test/base/scripts/sync.sh
+++ b/packages/react-router/test/base/scripts/sync.sh
@@ -15,4 +15,4 @@ npm pack ../../../../react
npm pack ../../../
# Install Dependencies
-npm install *.tgz --no-save
+npm install *.tgz --no-save --legacy-peer-deps
diff --git a/packages/react-router/test/base/src/App.test.tsx b/packages/react-router/test/base/src/App.test.tsx
index 8c927a8d7ac..b770c1c4d5c 100644
--- a/packages/react-router/test/base/src/App.test.tsx
+++ b/packages/react-router/test/base/src/App.test.tsx
@@ -1,5 +1,6 @@
-import React from 'react';
import { render } from '@testing-library/react';
+import React from 'react';
+
import App from './App';
test('renders without crashing', () => {
diff --git a/packages/react-router/test/base/src/App.tsx b/packages/react-router/test/base/src/App.tsx
index cdfa67d1360..9cc461a4176 100644
--- a/packages/react-router/test/base/src/App.tsx
+++ b/packages/react-router/test/base/src/App.tsx
@@ -1,6 +1,6 @@
import { IonApp, setupIonicReact, IonRouterOutlet } from '@ionic/react';
import React from 'react';
-import { Route } from 'react-router-dom';
+import { Route, Navigate } from 'react-router-dom';
/* Core CSS required for Ionic components to work properly */
import '@ionic/react/css/core.css';
@@ -21,22 +21,25 @@ import '@ionic/react/css/text-transformation.css';
/* Theme variables */
import './theme/variables.css';
import Main from './pages/Main';
+
import { IonReactRouter } from '@ionic/react-router';
+
+import DynamicIonpageClassnames from './pages/dynamic-ionpage-classnames/DynamicIonpageClassnames';
import DynamicRoutes from './pages/dynamic-routes/DynamicRoutes';
-import Routing from './pages/routing/Routing';
-import MultipleTabs from './pages/muiltiple-tabs/MultipleTabs';
import DynamicTabs from './pages/dynamic-tabs/DynamicTabs';
+import MultipleTabs from './pages/muiltiple-tabs/MultipleTabs';
import NestedOutlet from './pages/nested-outlet/NestedOutlet';
import NestedOutlet2 from './pages/nested-outlet/NestedOutlet2';
-import ReplaceAction from './pages/replace-action/Replace';
-import TabsContext from './pages/tab-context/TabContext';
+import NestedParams from './pages/nested-params/NestedParams';
import { OutletRef } from './pages/outlet-ref/OutletRef';
-import { SwipeToGoBack } from './pages/swipe-to-go-back/SwipToGoBack';
+import Params from './pages/params/Params';
import Refs from './pages/refs/Refs';
-import DynamicIonpageClassnames from './pages/dynamic-ionpage-classnames/DynamicIonpageClassnames';
+import { Page1, Page2, Page3 } from './pages/replace-action/Replace';
+import Routing from './pages/routing/Routing';
+import { SwipeToGoBack } from './pages/swipe-to-go-back/SwipToGoBack';
+import TabsContext from './pages/tab-context/TabContext';
import Tabs from './pages/tabs/Tabs';
import TabsSecondary from './pages/tabs/TabsSecondary';
-import Params from './pages/params/Params';
import Overlays from './pages/overlays/Overlays';
setupIonicReact();
@@ -46,23 +49,27 @@ const App: React.FC = () => {
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
diff --git a/packages/react-router/test/base/src/index.tsx b/packages/react-router/test/base/src/index.tsx
index 5ed41355a40..de6c73b77f4 100644
--- a/packages/react-router/test/base/src/index.tsx
+++ b/packages/react-router/test/base/src/index.tsx
@@ -1,5 +1,6 @@
import React from 'react';
import { createRoot } from 'react-dom/client';
+
import App from './App';
const container = document.getElementById('root');
diff --git a/packages/react-router/test/base/src/pages/Main.tsx b/packages/react-router/test/base/src/pages/Main.tsx
index cd4c5f6980c..da6f3dd0667 100644
--- a/packages/react-router/test/base/src/pages/Main.tsx
+++ b/packages/react-router/test/base/src/pages/Main.tsx
@@ -1,4 +1,3 @@
-import React from 'react';
import {
IonContent,
IonHeader,
@@ -9,15 +8,20 @@ import {
IonItem,
IonLabel,
} from '@ionic/react';
+import React from 'react';
-interface MainProps {}
+import packageJson from '../../package.json';
+
+const Main: React.FC = () => {
+ const majorVersion = packageJson.dependencies['react-router'].match(
+ /(\d+)\.(\d+)\.(\d+)/
+ )?.[1];
-const Main: React.FC = () => {
return (
- Main
+ Test App - React Router v{majorVersion}
@@ -67,6 +71,9 @@ const Main: React.FC = () => {
Params
+
+ Nested Params
+
diff --git a/packages/react-router/test/base/src/pages/dynamic-ionpage-classnames/DynamicIonpageClassnames.tsx b/packages/react-router/test/base/src/pages/dynamic-ionpage-classnames/DynamicIonpageClassnames.tsx
index baba236a0c6..9dbbb6b899c 100644
--- a/packages/react-router/test/base/src/pages/dynamic-ionpage-classnames/DynamicIonpageClassnames.tsx
+++ b/packages/react-router/test/base/src/pages/dynamic-ionpage-classnames/DynamicIonpageClassnames.tsx
@@ -1,4 +1,3 @@
-import React, { useEffect, useRef, useState } from 'react';
import {
IonButton,
IonContent,
@@ -8,14 +7,13 @@ import {
IonTitle,
IonToolbar,
} from '@ionic/react';
+import React, { useEffect, useRef, useState } from 'react';
import { Route } from 'react-router';
-interface DynamicIonpageClassnamesProps {}
-
-const DynamicIonpageClassnames: React.FC = () => {
+const DynamicIonpageClassnames: React.FC = () => {
return (
-
+ } />
);
};
@@ -27,8 +25,9 @@ const Page: React.FC = (props) => {
const [divClasses, setDivClasses] = useState();
const ref = useRef();
useEffect(() => {
+ let observer: MutationObserver | undefined;
if(ref.current) {
- var observer = new MutationObserver(function (event) {
+ observer = new MutationObserver(function (event) {
setDivClasses(ref.current?.className)
})
@@ -39,7 +38,7 @@ const Page: React.FC = (props) => {
characterData: false
})
}
- return () => observer.disconnect()
+ return () => observer?.disconnect()
}, [])
diff --git a/packages/react-router/test/base/src/pages/dynamic-routes/DynamicRoutes.tsx b/packages/react-router/test/base/src/pages/dynamic-routes/DynamicRoutes.tsx
index 89a598c4636..f91da67b4ff 100644
--- a/packages/react-router/test/base/src/pages/dynamic-routes/DynamicRoutes.tsx
+++ b/packages/react-router/test/base/src/pages/dynamic-routes/DynamicRoutes.tsx
@@ -1,4 +1,3 @@
-import React, { useState, ReactElement } from 'react';
import {
IonContent,
IonHeader,
@@ -7,32 +6,33 @@ import {
IonToolbar,
IonRouterOutlet,
} from '@ionic/react';
-import { Route, Redirect } from 'react-router';
+import React, { useState } from 'react';
+import type { ReactElement } from 'react';
+import { Route, Navigate } from 'react-router';
import { Link } from 'react-router-dom';
const DynamicRoutes: React.FC = () => {
+ const addRoute = () => {
+ const newRoute = (
+ } />
+ );
+ setRoutes([...routes, newRoute]);
+ };
+
const [routes, setRoutes] = useState([
}
- exact={true}
+ element={}
/>,
]);
- const addRoute = () => {
- const newRoute = (
-
- );
- setRoutes([...routes, newRoute]);
- };
-
return (
{routes}
- {/* } /> */}
- } />
- } />
+ {/* } /> */}
+ } />
+ } />
);
};
diff --git a/packages/react-router/test/base/src/pages/dynamic-tabs/DynamicTabs.tsx b/packages/react-router/test/base/src/pages/dynamic-tabs/DynamicTabs.tsx
index 5f2203dd389..cb1079328a8 100644
--- a/packages/react-router/test/base/src/pages/dynamic-tabs/DynamicTabs.tsx
+++ b/packages/react-router/test/base/src/pages/dynamic-tabs/DynamicTabs.tsx
@@ -1,11 +1,9 @@
-import React, { useState, useCallback } from 'react';
import {
IonContent,
IonHeader,
IonPage,
IonTitle,
IonToolbar,
- IonApp,
IonTabs,
IonRouterOutlet,
IonTabBar,
@@ -14,56 +12,36 @@ import {
IonLabel,
IonButton,
} from '@ionic/react';
-import { Route, Redirect } from 'react-router';
-import { IonReactRouter } from '@ionic/react-router';
import { triangle, square } from 'ionicons/icons';
+import React, { useState, useCallback } from 'react';
+import { Route, Navigate } from 'react-router';
const DynamicTabs: React.FC = () => {
const [display2ndTab, setDisplayThirdTab] = useState(false);
- const renderFirstTab = useCallback(() => {
- return setDisplayThirdTab(!display2ndTab)} />;
- }, [display2ndTab]);
-
- const render2ndTabRoute = useCallback(() => {
- if (display2ndTab) {
- return ;
- } else {
- // This is weird, if I return null or undefined then I get all sorts of errors, seemingly
- // because the router is mad about a child not being a route.
- return ;
- }
- }, [display2ndTab]);
-
return (
-
-
-
-
-
- {render2ndTabRoute()}
- }
- exact={true}
- />
- } />
-
-
-
-
- Tab 1
-
- {display2ndTab && (
-
-
- Tab 2
-
- )}
-
-
-
-
+
+
+ } />
+ : }
+ />
+ } />
+
+
+
+
+ Tab 1
+
+ {display2ndTab && (
+
+
+ Tab 2
+
+ )}
+
+
);
};
diff --git a/packages/react-router/test/base/src/pages/muiltiple-tabs/Menu.tsx b/packages/react-router/test/base/src/pages/muiltiple-tabs/Menu.tsx
index dbde2166f6d..92f8e378b86 100644
--- a/packages/react-router/test/base/src/pages/muiltiple-tabs/Menu.tsx
+++ b/packages/react-router/test/base/src/pages/muiltiple-tabs/Menu.tsx
@@ -1,5 +1,6 @@
-import React from 'react';
import { IonMenu, IonContent, IonList, IonItem, IonLabel, IonMenuToggle } from '@ionic/react';
+import React from 'react';
+
export const Menu: React.FC = () => {
return (
diff --git a/packages/react-router/test/base/src/pages/muiltiple-tabs/MultipleTabs.tsx b/packages/react-router/test/base/src/pages/muiltiple-tabs/MultipleTabs.tsx
index a0efdf560fd..5ccc4c371ad 100644
--- a/packages/react-router/test/base/src/pages/muiltiple-tabs/MultipleTabs.tsx
+++ b/packages/react-router/test/base/src/pages/muiltiple-tabs/MultipleTabs.tsx
@@ -1,4 +1,3 @@
-import React from 'react';
import {
IonSplitPane,
IonRouterOutlet,
@@ -15,10 +14,12 @@ import {
IonTitle,
IonToolbar,
} from '@ionic/react';
+import { triangle, ellipse, square, rocket } from 'ionicons/icons';
+import React from 'react';
+import { Route, Navigate } from 'react-router';
-import { Route, Redirect } from 'react-router';
import { Menu } from './Menu';
-import { triangle, ellipse, square, rocket } from 'ionicons/icons';
+
const MultipleTabs: React.FC = () => {
return (
@@ -26,24 +27,14 @@ const MultipleTabs: React.FC = () => {
{
- return ;
- }}
- exact={false}
- />
- {
- return ;
- }}
- exact={false}
+ path="/multiple-tabs/tab1/*"
+ element={}
/>
}
- exact={true}
+ path="/multiple-tabs/tab2/*"
+ element={}
/>
+ } />
);
@@ -68,12 +59,11 @@ const Tab1: React.FC = () => {
}
- exact={true}
+ element={}
/>
- {/* */}
- } exact={true} />
- } exact={true} />
+ {/* } /> */}
+ } />
+ } />
);
@@ -95,12 +85,11 @@ const Tab2: React.FC = () => {
}
- exact={true}
+ element={}
/>
- {/* */}
- } exact={true} />
- } exact={true} />
+ {/* } /> */}
+ } />
+ } />
);
diff --git a/packages/react-router/test/base/src/pages/nested-outlet/NestedOutlet.tsx b/packages/react-router/test/base/src/pages/nested-outlet/NestedOutlet.tsx
index 62aa9e1e7f6..a3ca6da2027 100644
--- a/packages/react-router/test/base/src/pages/nested-outlet/NestedOutlet.tsx
+++ b/packages/react-router/test/base/src/pages/nested-outlet/NestedOutlet.tsx
@@ -7,9 +7,8 @@ import {
IonTitle,
IonToolbar,
} from '@ionic/react';
-import { useEffect } from 'react';
-import React from 'react';
-import { Route, Redirect } from 'react-router';
+import React, { useEffect } from 'react';
+import { Route, Navigate } from 'react-router';
const Page: React.FC = () => {
useEffect(() => {
@@ -48,10 +47,9 @@ const SecondPage: React.FC = () => {
}
+ element={}
/>
-
+ } />
);
};
@@ -81,8 +79,8 @@ const FirstPage: React.FC = () => {
const NestedOutlet: React.FC = () => (
-
-
+ } />
+ } />
);
diff --git a/packages/react-router/test/base/src/pages/nested-outlet/NestedOutlet2.tsx b/packages/react-router/test/base/src/pages/nested-outlet/NestedOutlet2.tsx
index d62911046c8..dcd6d6cfa97 100644
--- a/packages/react-router/test/base/src/pages/nested-outlet/NestedOutlet2.tsx
+++ b/packages/react-router/test/base/src/pages/nested-outlet/NestedOutlet2.tsx
@@ -1,5 +1,3 @@
-import React from 'react';
-import { Redirect, Route, RouteComponentProps } from 'react-router-dom';
import {
IonBackButton,
IonButtons,
@@ -13,12 +11,14 @@ import {
IonTitle,
IonToolbar,
} from '@ionic/react';
+import React from 'react';
+import { Navigate, Route, useParams } from 'react-router-dom';
-const ListPage: React.FC = ({ match }) => {
+const ListPage: React.FC = () => {
return (
-
-
+ } />
+ } />
);
};
@@ -51,7 +51,9 @@ const List: React.FC = () => {
);
};
-const Item: React.FC> = ({ match }) => {
+const Item: React.FC = () => {
+ const { id } = useParams<{ id: string }>();
+
return (
@@ -62,16 +64,16 @@ const Item: React.FC> = ({ match }) => {
Item
- Detail of item #{match.params.id}
+ Detail of item #{id}
);
};
-const HomePage: React.FC = ({ match }) => {
+const HomePage: React.FC = () => {
return (
-
-
+ } />
+ } />
);
};
@@ -98,7 +100,7 @@ const Welcome: React.FC = () => {
);
};
-const Home: React.FC> = ({ match }) => {
+const Home: React.FC = () => {
return (
@@ -122,12 +124,11 @@ const Home: React.FC> = ({ match }) => {
const NestedOutlet2: React.FC = () => (
-
-
+ } />
+ } />
}
- exact={true}
+ element={}
/>
);
diff --git a/packages/react-router/test/base/src/pages/nested-params/NestedParams.tsx b/packages/react-router/test/base/src/pages/nested-params/NestedParams.tsx
new file mode 100644
index 00000000000..a92f5253062
--- /dev/null
+++ b/packages/react-router/test/base/src/pages/nested-params/NestedParams.tsx
@@ -0,0 +1,110 @@
+import {
+ IonButton,
+ IonContent,
+ IonHeader,
+ IonLabel,
+ IonPage,
+ IonRouterOutlet,
+ IonTitle,
+ IonToolbar,
+} from '@ionic/react';
+import React from 'react';
+import { Navigate, Route } from 'react-router';
+import { useParams } from 'react-router-dom';
+
+const NestedParamsRoot: React.FC = () => (
+
+
+
+ Nested Params
+
+
+
+
+ } />
+ } />
+
+
+
+);
+
+const Landing: React.FC = () => (
+
+
+
+ Select a User
+
+
+
+ A nested route will try to read the parent :userId parameter.
+
+ Go to User 42 Details
+
+
+ Go to User 99 Details
+
+
+
+);
+
+const UserLayout: React.FC = () => {
+ const { userId } = useParams<{ userId: string }>();
+
+ return (
+
+
+
+ User {userId ?? 'missing'}
+
+
+
+ Layout sees user: {userId ?? 'missing'}
+
+ } />
+ } />
+ } />
+
+
+
+ );
+};
+
+const UserDetails: React.FC = () => {
+ const { userId } = useParams<{ userId: string }>();
+
+ return (
+
+
+
+ Details
+
+
+
+ Details view user: {userId ?? 'missing'}
+
+ Go to Settings
+
+
+
+ );
+};
+
+const UserSettings: React.FC = () => {
+ const { userId } = useParams<{ userId: string }>();
+
+ return (
+
+
+
+ Settings
+
+
+
+ Settings view user: {userId ?? 'missing'}
+ Back to Details
+
+
+ );
+};
+
+export default NestedParamsRoot;
diff --git a/packages/react-router/test/base/src/pages/outlet-ref/OutletRef.tsx b/packages/react-router/test/base/src/pages/outlet-ref/OutletRef.tsx
index 9aa3e9e0ba9..53c9c2ffebc 100644
--- a/packages/react-router/test/base/src/pages/outlet-ref/OutletRef.tsx
+++ b/packages/react-router/test/base/src/pages/outlet-ref/OutletRef.tsx
@@ -1,4 +1,3 @@
-import React, { useRef, useEffect } from 'react';
import {
IonRouterOutlet,
IonPage,
@@ -7,24 +6,25 @@ import {
IonTitle,
IonContent,
} from '@ionic/react';
+import React, { useRef, useEffect, useState } from 'react';
import { Route } from 'react-router';
-interface OutletRefProps {}
-
-export const OutletRef: React.FC = () => {
+export const OutletRef: React.FC = () => {
const ref = useRef(null);
+ const [outletId, setOutletId] = useState(undefined);
useEffect(() => {
- console.log(ref);
+ // Update the outlet id once the ref is populated
+ if (ref.current?.id) {
+ setOutletId(ref.current.id);
+ }
}, []);
return (
{
- return ;
- }}
+ element={}
/>
);
diff --git a/packages/react-router/test/base/src/pages/overlays/Overlays.tsx b/packages/react-router/test/base/src/pages/overlays/Overlays.tsx
index 9343df83b97..19fe9575bb8 100644
--- a/packages/react-router/test/base/src/pages/overlays/Overlays.tsx
+++ b/packages/react-router/test/base/src/pages/overlays/Overlays.tsx
@@ -1,15 +1,15 @@
import { IonButton, IonContent, IonModal } from '@ionic/react';
import { useState } from 'react';
-import { useHistory } from 'react-router';
+import { useNavigate } from 'react-router-dom';
const Overlays: React.FC = () => {
const [isOpen, setIsOpen] = useState(false);
- const history = useHistory();
+ const navigate = useNavigate();
- const goBack = () => history.goBack();
- const replace = () => history.replace('/');
- const push = () => history.push('/');
+ const goBack = () => navigate(-1);
+ const replace = () => navigate('/', { replace: true });
+ const push = () => navigate('/');
return (
<>
diff --git a/packages/react-router/test/base/src/pages/params/Params.tsx b/packages/react-router/test/base/src/pages/params/Params.tsx
index 18f49d45888..b60903bb1c3 100644
--- a/packages/react-router/test/base/src/pages/params/Params.tsx
+++ b/packages/react-router/test/base/src/pages/params/Params.tsx
@@ -1,4 +1,3 @@
-import React from 'react';
import {
IonButtons,
IonBackButton,
@@ -9,30 +8,29 @@ import {
IonTitle,
IonToolbar,
} from '@ionic/react';
-import { RouteComponentProps } from 'react-router';
-
-interface PageProps
-extends RouteComponentProps<{
- id: string;
-}> {}
+import React from 'react';
+import { useParams } from 'react-router-dom';
+const Page: React.FC = () => {
+ const { id } = useParams<{ id: string }>();
+ const parseID = id ? parseInt(id) : NaN;
+ const displayId = id || 'N/A';
+ const nextParamLink = !isNaN(parseID) ? `/params/${parseID + 1}` : '/params/1';
-const Page: React.FC = ({ match }) => {
- const parseID = parseInt(match.params.id);
return (
-
+
- Params { match.params.id }
+ Params { displayId }
- Go to next param
+ Go to next param
- Page ID: { match.params.id }
+ Page ID: { displayId }
);
diff --git a/packages/react-router/test/base/src/pages/refs/Refs.tsx b/packages/react-router/test/base/src/pages/refs/Refs.tsx
index 7ac15c83ab3..9b20848e9fc 100644
--- a/packages/react-router/test/base/src/pages/refs/Refs.tsx
+++ b/packages/react-router/test/base/src/pages/refs/Refs.tsx
@@ -1,27 +1,26 @@
-import React, { useRef } from "react";
import {
IonContent,
IonHeader,
IonPage,
IonRouterOutlet,
IonTitle,
+ IonText,
IonToolbar,
} from "@ionic/react";
+import React, { useRef } from "react";
import { Route } from "react-router";
-interface RefsProps {}
-
const Refs: React.FC = () => {
return (
- {/* } /> */}
-
-
+ {/* } /> */}
+ } />
+ } />
);
};
-const RefsFC: React.FC = () => {
+const RefsFC: React.FC = () => {
const contentRef = useRef(null);
return (
@@ -30,7 +29,11 @@ const RefsFC: React.FC = () => {
Refs FC
-
+
+
+ This view is used for automated ref regression tests.
+
+
);
};
@@ -45,7 +48,11 @@ class RefsClass extends React.Component {
Refs Class
-
+
+
+ This view is used for automated ref regression tests.
+
+
);
}
diff --git a/packages/react-router/test/base/src/pages/replace-action/Replace.tsx b/packages/react-router/test/base/src/pages/replace-action/Replace.tsx
index 2f4e3f38de7..5ec4b53d30b 100644
--- a/packages/react-router/test/base/src/pages/replace-action/Replace.tsx
+++ b/packages/react-router/test/base/src/pages/replace-action/Replace.tsx
@@ -1,4 +1,3 @@
-import React from 'react';
import {
IonContent,
IonHeader,
@@ -6,24 +5,15 @@ import {
IonTitle,
IonToolbar,
IonButton,
- IonRouterOutlet,
IonButtons,
IonBackButton,
} from '@ionic/react';
-import { Route, Redirect, useHistory } from 'react-router';
-
-interface TopPageProps {}
+import React from 'react';
+import { useNavigate } from 'react-router-dom';
-const ReplaceAction: React.FC = () => {
- return (
-
-
-
-
- } />
-
- );
-};
+// ReplaceAction is no longer used as a component wrapper
+// Routes are defined directly in App.tsx
+const ReplaceAction: React.FC = () => null;
const Page1: React.FC = () => (
@@ -42,10 +32,10 @@ const Page1: React.FC = () => (
);
const Page2: React.FC = () => {
- const history = useHistory();
+ const navigate = useNavigate();
const clickButton = () => {
- history.replace('/replace-action/page3');
+ navigate('/replace-action/page3', { replace: true });
};
return (
@@ -84,3 +74,4 @@ const Page3: React.FC = () => {
};
export default ReplaceAction;
+export { Page1, Page2, Page3 };
diff --git a/packages/react-router/test/base/src/pages/routing/Details.tsx b/packages/react-router/test/base/src/pages/routing/Details.tsx
index 94aeeb6dbaf..5e97a48cae3 100644
--- a/packages/react-router/test/base/src/pages/routing/Details.tsx
+++ b/packages/react-router/test/base/src/pages/routing/Details.tsx
@@ -1,4 +1,3 @@
-import React, { useEffect } from 'react';
import {
IonContent,
IonHeader,
@@ -10,11 +9,10 @@ import {
IonLabel,
IonButton,
} from '@ionic/react';
-import { useParams, useLocation } from 'react-router';
-
-interface DetailsProps {}
+import React, { useEffect } from 'react';
+import { useParams, useLocation } from 'react-router-dom';
-const Details: React.FC = () => {
+const Details: React.FC = () => {
const { id } = useParams<{ id: string }>();
const location = useLocation();
@@ -24,7 +22,7 @@ const Details: React.FC = () => {
return () => console.log('Home Details unmount');
}, []);
- const nextId = parseInt(id, 10) + 1;
+ const nextId = parseInt(id ?? '0', 10) + 1;
return (
diff --git a/packages/react-router/test/base/src/pages/routing/Favorites.tsx b/packages/react-router/test/base/src/pages/routing/Favorites.tsx
index 7a3c80d2e39..7c8b9cb1142 100644
--- a/packages/react-router/test/base/src/pages/routing/Favorites.tsx
+++ b/packages/react-router/test/base/src/pages/routing/Favorites.tsx
@@ -1,4 +1,3 @@
-import React, { useEffect } from 'react';
import {
IonContent,
IonHeader,
@@ -9,10 +8,9 @@ import {
IonMenuButton,
useIonViewWillEnter,
} from '@ionic/react';
+import React, { useEffect } from 'react';
-interface FavoritesProps {}
-
-const Favorites: React.FC = () => {
+const Favorites: React.FC = () => {
useIonViewWillEnter(() => {
console.log('IVWE on Favorites');
});
diff --git a/packages/react-router/test/base/src/pages/routing/Menu.tsx b/packages/react-router/test/base/src/pages/routing/Menu.tsx
index d53d9ad0a70..763a31a56bd 100644
--- a/packages/react-router/test/base/src/pages/routing/Menu.tsx
+++ b/packages/react-router/test/base/src/pages/routing/Menu.tsx
@@ -8,10 +8,8 @@ import {
IonMenu,
IonMenuToggle,
} from '@ionic/react';
-import React from 'react';
import { heartOutline, heartSharp, mailOutline, mailSharp } from 'ionicons/icons';
-
-interface MenuProps {}
+import React from 'react';
interface AppPage {
url: string;
@@ -53,7 +51,7 @@ const appPages: AppPage[] = [
},
];
-const Menu: React.FunctionComponent = () => {
+const Menu: React.FunctionComponent = () => {
return (
diff --git a/packages/react-router/test/base/src/pages/routing/OtherPage.tsx b/packages/react-router/test/base/src/pages/routing/OtherPage.tsx
index 6f1138329fc..b1e452e9bb0 100644
--- a/packages/react-router/test/base/src/pages/routing/OtherPage.tsx
+++ b/packages/react-router/test/base/src/pages/routing/OtherPage.tsx
@@ -1,4 +1,3 @@
-import React, { useEffect } from 'react';
import {
IonContent,
IonHeader,
@@ -10,10 +9,9 @@ import {
useIonViewWillEnter,
IonButton,
} from '@ionic/react';
+import React, { useEffect } from 'react';
-interface OtherPageProps {}
-
-const OtherPage: React.FC = () => {
+const OtherPage: React.FC = () => {
useIonViewWillEnter(() => {
console.log('IVWE on otherpage');
});
@@ -25,7 +23,7 @@ const OtherPage: React.FC = () => {
return (
//
- // (
+ //
@@ -39,7 +37,7 @@ const OtherPage: React.FC = () => {
Go to tab3
- // )}>
+ // }>
//
);
};
diff --git a/packages/react-router/test/base/src/pages/routing/PropsTest.tsx b/packages/react-router/test/base/src/pages/routing/PropsTest.tsx
index d8cc386a1b8..a0d33c238e6 100644
--- a/packages/react-router/test/base/src/pages/routing/PropsTest.tsx
+++ b/packages/react-router/test/base/src/pages/routing/PropsTest.tsx
@@ -1,4 +1,3 @@
-import React, { useState, useEffect } from 'react';
import {
IonContent,
IonHeader,
@@ -8,11 +7,10 @@ import {
IonButton,
IonRouterOutlet,
} from '@ionic/react';
+import React, { useState, useEffect } from 'react';
import { Route } from 'react-router';
-interface PropsTestProps {}
-
-const PropsTest: React.FC = () => {
+const PropsTest: React.FC = () => {
const [count, setCount] = useState(1);
useEffect(() => {
console.log(count);
@@ -21,7 +19,7 @@ const PropsTest: React.FC = () => {
}
+ element={}
/>
);
diff --git a/packages/react-router/test/base/src/pages/routing/RedirectRouting.tsx b/packages/react-router/test/base/src/pages/routing/RedirectRouting.tsx
index cd63689b779..559cf6a9d4f 100644
--- a/packages/react-router/test/base/src/pages/routing/RedirectRouting.tsx
+++ b/packages/react-router/test/base/src/pages/routing/RedirectRouting.tsx
@@ -1,5 +1,6 @@
-import React, { useEffect, useContext } from 'react';
import { IonRouterContext } from '@ionic/react';
+import type React from 'react';
+import { useEffect, useContext } from 'react';
const RedirectRouting: React.FC = () => {
const ionRouterContext = useContext(IonRouterContext);
diff --git a/packages/react-router/test/base/src/pages/routing/Routing.tsx b/packages/react-router/test/base/src/pages/routing/Routing.tsx
index 5fcf7c133fc..ef9cf5dc4a6 100644
--- a/packages/react-router/test/base/src/pages/routing/Routing.tsx
+++ b/packages/react-router/test/base/src/pages/routing/Routing.tsx
@@ -1,57 +1,43 @@
-import React from 'react';
import {
IonContent,
IonPage,
IonRouterOutlet,
IonSplitPane,
} from '@ionic/react';
-import Menu from './Menu';
-import { Route, Redirect } from 'react-router';
-import Tabs from './Tabs';
+import React from 'react';
+import { Route, Navigate } from 'react-router';
+
import Favorites from './Favorites';
+import Menu from './Menu';
import OtherPage from './OtherPage';
import PropsTest from './PropsTest';
import RedirectRouting from './RedirectRouting';
+import Tabs from './Tabs';
-interface RoutingProps {}
-
-const Routing: React.FC = () => {
+const Routing: React.FC = () => {
return (
- } />
- {/* */}
- } exact />
-
- {/* {
- return (
-
-
-
- );
- }} /> */}
- {/* {
- return (
-
-
-
- );
- }} /> */}
-
-
- } />
- } />
+ } />
+
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+
(
+ path="*"
+ element={
- Not found
+ Not found in routing.tsx
- )}
+ }
/>
- {/* } /> */}
);
diff --git a/packages/react-router/test/base/src/pages/routing/SettingsDetails.tsx b/packages/react-router/test/base/src/pages/routing/SettingsDetails.tsx
index 0dc5b0be6ff..335aefbf8c3 100644
--- a/packages/react-router/test/base/src/pages/routing/SettingsDetails.tsx
+++ b/packages/react-router/test/base/src/pages/routing/SettingsDetails.tsx
@@ -1,4 +1,3 @@
-import React, { useEffect } from 'react';
import {
IonContent,
IonHeader,
@@ -10,11 +9,10 @@ import {
IonLabel,
IonButton,
} from '@ionic/react';
-import { useParams } from 'react-router';
-
-interface DetailsProps {}
+import React, { useEffect } from 'react';
+import { useParams } from 'react-router-dom';
-const SettingsDetails: React.FC = () => {
+const SettingsDetails: React.FC = () => {
const { id } = useParams<{ id: string }>();
useEffect(() => {
@@ -22,7 +20,7 @@ const SettingsDetails: React.FC = () => {
return () => console.log('Settings Details unmount');
}, []);
- const nextId = parseInt(id, 10) + 1;
+ const nextId = parseInt(id ?? '0', 10) + 1;
// LEFT OFF - why is back button not working for multiple entries?
return (
diff --git a/packages/react-router/test/base/src/pages/routing/Tab1.tsx b/packages/react-router/test/base/src/pages/routing/Tab1.tsx
index bf14ce1456d..d9e204d55ad 100644
--- a/packages/react-router/test/base/src/pages/routing/Tab1.tsx
+++ b/packages/react-router/test/base/src/pages/routing/Tab1.tsx
@@ -1,4 +1,3 @@
-import React, { useEffect, useContext } from 'react';
import {
IonContent,
IonHeader,
@@ -14,6 +13,7 @@ import {
IonButton,
IonRouterContext,
} from '@ionic/react';
+import React, { useEffect, useContext } from 'react';
import './Tab1.css';
import { Link } from 'react-router-dom';
@@ -54,8 +54,8 @@ const Tab1: React.FC = () => {
Details 1
-
- Details 1 & Unmount
+
+ Details 1 (alt)
Details 1 with Query Params
diff --git a/packages/react-router/test/base/src/pages/routing/Tab2.tsx b/packages/react-router/test/base/src/pages/routing/Tab2.tsx
index a6437dec41f..7d86d13933c 100644
--- a/packages/react-router/test/base/src/pages/routing/Tab2.tsx
+++ b/packages/react-router/test/base/src/pages/routing/Tab2.tsx
@@ -1,4 +1,3 @@
-import React, { useEffect } from 'react';
import {
IonContent,
IonHeader,
@@ -12,11 +11,12 @@ import {
IonMenuButton,
IonButton,
} from '@ionic/react';
+import React, { useEffect } from 'react';
import './Tab2.css';
-import { useHistory } from 'react-router';
+import { useNavigate } from 'react-router-dom';
const Tab2: React.FC = () => {
- const history = useHistory();
+ const navigate = useNavigate();
useEffect(() => {
console.log('Settings mount');
@@ -51,10 +51,10 @@ const Tab2: React.FC = () => {
{
- history.push('/routing/tabs/settings/details/1', { routerOptions: { unmount: true } });
+ navigate('/routing/tabs/settings/details/1');
}}
>
- Details with Unmount via history.push
+ Details with Unmount via navigate
diff --git a/packages/react-router/test/base/src/pages/routing/Tab3.tsx b/packages/react-router/test/base/src/pages/routing/Tab3.tsx
index d4564de0a37..d05a3ab9bc6 100644
--- a/packages/react-router/test/base/src/pages/routing/Tab3.tsx
+++ b/packages/react-router/test/base/src/pages/routing/Tab3.tsx
@@ -1,4 +1,3 @@
-import React from 'react';
import {
IonContent,
IonHeader,
@@ -10,6 +9,7 @@ import {
IonMenuButton,
IonButton,
} from '@ionic/react';
+import React from 'react';
import './Tab3.css';
class Tab3 extends React.Component {
diff --git a/packages/react-router/test/base/src/pages/routing/Tabs.tsx b/packages/react-router/test/base/src/pages/routing/Tabs.tsx
index 3ded8d9ee99..d70ffd7caaf 100644
--- a/packages/react-router/test/base/src/pages/routing/Tabs.tsx
+++ b/packages/react-router/test/base/src/pages/routing/Tabs.tsx
@@ -1,41 +1,40 @@
+import { IonTabs, IonRouterOutlet, IonTabBar, IonTabButton, IonIcon, IonLabel, IonPage, IonContent } from '@ionic/react';
+import { triangle, ellipse, square } from 'ionicons/icons';
import React from 'react';
-import { IonTabs, IonRouterOutlet, IonTabBar, IonTabButton, IonIcon, IonLabel } from '@ionic/react';
-import { Route, Redirect } from 'react-router';
-import Tab1 from './Tab1';
+import { Route, Navigate } from 'react-router';
+
import Details from './Details';
+import SettingsDetails from './SettingsDetails';
+import Tab1 from './Tab1';
import Tab2 from './Tab2';
import Tab3 from './Tab3';
-import { triangle, ellipse, square } from 'ionicons/icons';
-import SettingsDetails from './SettingsDetails';
-interface TabsProps {}
-const Tabs: React.FC = () => {
+
+const Tabs: React.FC = () => {
return (
-
-
- {/* {
- return
- }} exact={true} /> */}
-
-
-
- }
- exact={true}
- />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
}
- exact={true}
+ path="*"
+ element={
+
+
+ Not found in tabs.tsx
+
+
+ }
/>
- {/* } />} /> */}
-
+
Home
diff --git a/packages/react-router/test/base/src/pages/swipe-to-go-back/SwipToGoBack.tsx b/packages/react-router/test/base/src/pages/swipe-to-go-back/SwipToGoBack.tsx
index 2b88551b85e..72fed8f819f 100644
--- a/packages/react-router/test/base/src/pages/swipe-to-go-back/SwipToGoBack.tsx
+++ b/packages/react-router/test/base/src/pages/swipe-to-go-back/SwipToGoBack.tsx
@@ -1,4 +1,3 @@
-import React from 'react';
import {
IonRouterOutlet,
IonPage,
@@ -10,15 +9,14 @@ import {
IonButtons,
IonBackButton,
} from '@ionic/react';
+import React from 'react';
import { Route } from 'react-router';
-interface SwipeToGoBackProps {}
-
-export const SwipeToGoBack: React.FC = () => {
+export const SwipeToGoBack: React.FC = () => {
return (
-
-
+ } />
+ } />
);
};
diff --git a/packages/react-router/test/base/src/pages/tab-context/TabContext.tsx b/packages/react-router/test/base/src/pages/tab-context/TabContext.tsx
index b89cface216..e200f1e8112 100644
--- a/packages/react-router/test/base/src/pages/tab-context/TabContext.tsx
+++ b/packages/react-router/test/base/src/pages/tab-context/TabContext.tsx
@@ -1,4 +1,3 @@
-import React, { useContext } from 'react';
import {
IonTabs,
IonRouterOutlet,
@@ -16,21 +15,20 @@ import {
IonTabsContext,
IonButton,
} from '@ionic/react';
-import { Route, Redirect } from 'react-router';
import { triangle, square } from 'ionicons/icons';
+import React, { useContext } from 'react';
+import { Route, Navigate } from 'react-router';
-interface TabsContextProps {}
-
-const TabsContext: React.FC = () => {
+const TabsContext: React.FC = () => {
return (
-
-
-
+ } />
+ } />
+ } />
-
+
Tab1
diff --git a/packages/react-router/test/base/src/pages/tabs/Tabs.tsx b/packages/react-router/test/base/src/pages/tabs/Tabs.tsx
index d27907821d9..3f887829be9 100644
--- a/packages/react-router/test/base/src/pages/tabs/Tabs.tsx
+++ b/packages/react-router/test/base/src/pages/tabs/Tabs.tsx
@@ -1,4 +1,3 @@
-import React from 'react';
import {
IonTabs,
IonRouterOutlet,
@@ -15,20 +14,19 @@ import {
IonContent,
IonButton,
} from '@ionic/react';
-import { Route, Redirect } from 'react-router';
import { triangle, square } from 'ionicons/icons';
+import React from 'react';
+import { Route, Navigate } from 'react-router';
-interface TabsProps {}
-
-const Tabs: React.FC = () => {
+const Tabs: React.FC = () => {
return (
-
-
-
-
-
+ } />
+ } />
+ } />
+ } />
+ } />
diff --git a/packages/react-router/test/base/src/pages/tabs/TabsSecondary.tsx b/packages/react-router/test/base/src/pages/tabs/TabsSecondary.tsx
index ab4103340d0..0df9a12460f 100644
--- a/packages/react-router/test/base/src/pages/tabs/TabsSecondary.tsx
+++ b/packages/react-router/test/base/src/pages/tabs/TabsSecondary.tsx
@@ -1,4 +1,3 @@
-import React from 'react';
import {
IonTabs,
IonRouterOutlet,
@@ -14,18 +13,17 @@ import {
IonTitle,
IonContent,
} from '@ionic/react';
-import { Route, Redirect } from 'react-router';
import { triangle, square } from 'ionicons/icons';
+import React from 'react';
+import { Route, Navigate } from 'react-router';
-interface TabsSecondaryProps {}
-
-const TabsSecondary: React.FC = () => {
+const TabsSecondary: React.FC = () => {
return (
-
-
-
+ } />
+ } />
+ } />
diff --git a/packages/react-router/test/base/src/react-app-env.d.ts b/packages/react-router/test/base/src/react-app-env.d.ts
index 1f35a7e91aa..9a8fcf86803 100644
--- a/packages/react-router/test/base/src/react-app-env.d.ts
+++ b/packages/react-router/test/base/src/react-app-env.d.ts
@@ -40,7 +40,7 @@ declare module '*.webp' {
}
declare module '*.svg' {
- import * as React from 'react';
+ import type * as React from 'react';
export const ReactComponent: React.FunctionComponent<
React.SVGProps & { title?: string }
diff --git a/packages/react-router/test/base/src/utils/LocationHistory.ts b/packages/react-router/test/base/src/utils/LocationHistory.ts
deleted file mode 100644
index ec5eee44ef6..00000000000
--- a/packages/react-router/test/base/src/utils/LocationHistory.ts
+++ /dev/null
@@ -1,45 +0,0 @@
-import { 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];
- }
-}
diff --git a/packages/react-router/test/base/src/utils/generateId.ts b/packages/react-router/test/base/src/utils/generateId.ts
index 06bb0a39a6f..6a37c44c6ed 100644
--- a/packages/react-router/test/base/src/utils/generateId.ts
+++ b/packages/react-router/test/base/src/utils/generateId.ts
@@ -1,7 +1,7 @@
const ids: { [key: string]: number } = { main: 1 };
-export const generateId = (type: string = 'main') => {
- let id = (ids[type] ?? 1) + 1;
+export const generateId = (type = 'main') => {
+ const id = (ids[type] ?? 1) + 1;
ids[type] = id;
return id.toString();
};
diff --git a/packages/react-router/test/base/tests/e2e/plugins/index.js b/packages/react-router/test/base/tests/e2e/plugins/index.js
index 8dd144a6c1a..a9fbe480369 100644
--- a/packages/react-router/test/base/tests/e2e/plugins/index.js
+++ b/packages/react-router/test/base/tests/e2e/plugins/index.js
@@ -18,4 +18,5 @@
module.exports = (on, config) => {
// `on` is used to hook into various events Cypress emits
// `config` is the resolved Cypress config
+ require('cypress-terminal-report/src/installLogsPrinter')(on);
};
diff --git a/packages/react-router/test/base/tests/e2e/specs/cross-route-navigation.cy.js b/packages/react-router/test/base/tests/e2e/specs/cross-route-navigation.cy.js
new file mode 100644
index 00000000000..e3b57ac7009
--- /dev/null
+++ b/packages/react-router/test/base/tests/e2e/specs/cross-route-navigation.cy.js
@@ -0,0 +1,168 @@
+const port = 3000;
+
+describe('Cross-Route Navigation', () => {
+ /**
+ * This test verifies that navigation between different top-level routes works correctly.
+ *
+ * Routing uses and MultipleTabs uses
+ * , ensuring view isolation between outlets.
+ */
+ it('should navigate from home to routing and back correctly', () => {
+ // Start at home
+ cy.visit(`http://localhost:${port}/`);
+ cy.ionPageVisible('home');
+
+ // Navigate to routing by clicking the link
+ cy.contains('ion-item', 'Routing').click();
+
+ // Routing should redirect to /routing/tabs/home and show the home-page
+ cy.ionPageVisible('home-page');
+
+ // Go back to the main home page using browser back
+ cy.go('back');
+
+ // Home page should be visible again
+ cy.ionPageVisible('home');
+ });
+
+ it('should navigate from home to multiple-tabs correctly', () => {
+ // Start at home
+ cy.visit(`http://localhost:${port}/`);
+ cy.ionPageVisible('home');
+
+ // Navigate to multiple-tabs
+ cy.contains('ion-item', 'Multiple Tabs').click();
+
+ // Multiple tabs should redirect to /multiple-tabs/tab1/pagea and show PageA
+ cy.ionPageVisible('PageA');
+ });
+
+ it('should navigate home -> routing -> back -> routing again', () => {
+ // Start at home
+ cy.visit(`http://localhost:${port}/`);
+ cy.ionPageVisible('home');
+
+ // Navigate to routing
+ cy.contains('ion-item', 'Routing').click();
+ cy.ionPageVisible('home-page');
+
+ // Go back to home
+ cy.go('back');
+ cy.ionPageVisible('home');
+
+ // Navigate to routing again - Navigate should fire again
+ cy.contains('ion-item', 'Routing').click();
+ cy.ionPageVisible('home-page');
+ });
+
+ it('should navigate home -> multiple-tabs -> back -> multiple-tabs again', () => {
+ // Start at home
+ cy.visit(`http://localhost:${port}/`);
+ cy.ionPageVisible('home');
+
+ // Navigate to multiple-tabs
+ cy.contains('ion-item', 'Multiple Tabs').click();
+ cy.ionPageVisible('PageA');
+
+ // Go back to home
+ cy.go('back');
+ cy.ionPageVisible('home');
+
+ // Navigate to multiple-tabs again - Navigate should fire again
+ cy.contains('ion-item', 'Multiple Tabs').click();
+ cy.ionPageVisible('PageA');
+ });
+
+ /**
+ * This test verifies behavior when navigating between different top-level routes
+ * that use separate outlet IDs. With unique outlet IDs, view items are completely
+ * isolated between outlets, so "stale views" from one outlet don't interfere with
+ * another outlet.
+ *
+ * This test uses ionPageDoesNotExist instead of ionPageHidden because views from
+ * one route hierarchy (like /routing/*) are completely unmounted (not just hidden)
+ * when navigating to a different route hierarchy (like /multiple-tabs/*).
+ */
+ it('should navigate home -> routing -> home -> multiple-tabs without stale views', () => {
+ // Start at home
+ cy.visit(`http://localhost:${port}/`);
+ cy.ionPageVisible('home');
+
+ // Navigate to routing
+ cy.contains('ion-item', 'Routing').click();
+ cy.ionPageVisible('home-page');
+ cy.url().should('include', '/routing/tabs/home');
+
+ // Go back to home
+ cy.go('back');
+ cy.ionPageVisible('home');
+
+ // Navigate to multiple-tabs - this is where stale views could interfere
+ cy.contains('ion-item', 'Multiple Tabs').click();
+ cy.ionPageVisible('PageA');
+ cy.url().should('include', '/multiple-tabs/tab1/pagea');
+
+ // The routing home-page should NOT exist in the DOM (views are unmounted when leaving route)
+ cy.ionPageDoesNotExist('home-page');
+ });
+
+ /**
+ * Test the reverse: multiple-tabs -> home -> routing
+ * With unique outlet IDs, views are isolated and properly unmounted.
+ */
+ it('should navigate home -> multiple-tabs -> home -> routing without stale views', () => {
+ // Start at home
+ cy.visit(`http://localhost:${port}/`);
+ cy.ionPageVisible('home');
+
+ // Navigate to multiple-tabs
+ cy.contains('ion-item', 'Multiple Tabs').click();
+ cy.ionPageVisible('PageA');
+ cy.url().should('include', '/multiple-tabs/tab1/pagea');
+
+ // Go back to home
+ cy.go('back');
+ cy.ionPageVisible('home');
+
+ // Navigate to routing - stale views from multiple-tabs should be cleaned up
+ cy.contains('ion-item', 'Routing').click();
+ cy.ionPageVisible('home-page');
+ cy.url().should('include', '/routing/tabs/home');
+
+ // PageA from multiple-tabs should NOT exist in the DOM (views are unmounted when leaving route)
+ cy.ionPageDoesNotExist('PageA');
+ });
+
+ /**
+ * Test navigating to another page and back to routing
+ * With unique outlet IDs, views are isolated and properly unmounted.
+ */
+ it('should not have overlay issues when navigating between different routes', () => {
+ // Start at home
+ cy.visit(`http://localhost:${port}/`);
+ cy.ionPageVisible('home');
+
+ // Navigate to routing
+ cy.contains('ion-item', 'Routing').click();
+ cy.ionPageVisible('home-page');
+
+ // Go back to home
+ cy.go('back');
+ cy.ionPageVisible('home');
+
+ // Navigate to multiple-tabs
+ cy.contains('ion-item', 'Multiple Tabs').click();
+ cy.ionPageVisible('PageA');
+
+ // Go back to home again
+ cy.go('back');
+ cy.ionPageVisible('home');
+
+ // Navigate back to routing - no stale views should overlay
+ cy.contains('ion-item', 'Routing').click();
+ cy.ionPageVisible('home-page');
+
+ // Verify PageA does not exist in the DOM (views are unmounted when leaving route)
+ cy.ionPageDoesNotExist('PageA');
+ });
+});
diff --git a/packages/react-router/test/base/tests/e2e/specs/dynamic-routes.cy.js b/packages/react-router/test/base/tests/e2e/specs/dynamic-routes.cy.js
index 76cc14ad7b4..9e2210f47a3 100644
--- a/packages/react-router/test/base/tests/e2e/specs/dynamic-routes.cy.js
+++ b/packages/react-router/test/base/tests/e2e/specs/dynamic-routes.cy.js
@@ -5,6 +5,11 @@ Fixes bug reported in https://github.com/ionic-team/ionic-framework/issues/21329
*/
describe('Dynamic Routes', () => {
+ it('/dynamic-routes/home loads directly', () => {
+ cy.visit(`http://localhost:${port}/dynamic-routes/home`);
+ cy.ionPageVisible('dynamic-routes-home');
+ });
+
it('/dynamic-routes, when adding a dynamic route, we should be able to navigate to it', () => {
cy.visit(`http://localhost:${port}/dynamic-routes`);
cy.ionPageVisible('dynamic-routes-home');
diff --git a/packages/react-router/test/base/tests/e2e/specs/nested-outlets.cy.js b/packages/react-router/test/base/tests/e2e/specs/nested-outlets.cy.js
index d43d2cc64d5..f00a8783b92 100644
--- a/packages/react-router/test/base/tests/e2e/specs/nested-outlets.cy.js
+++ b/packages/react-router/test/base/tests/e2e/specs/nested-outlets.cy.js
@@ -83,12 +83,7 @@ describe('Nested Outlets 2', () => {
cy.ionPageVisible('list');
});
- it(`/nested-outlet2 >
- Go to Welcome IonItem click >
- Go to list from Welcome IonItem click >
- Item#1 IonItem Click >
- Item page should be visible
-`, () => {
+ it('/nested-outlet2 > Go to Welcome IonItem click > Go to list from Welcome IonItem click > Item#1 IonItem Click > Item page should be visible', () => {
cy.visit(`http://localhost:${port}/nested-outlet2`);
cy.ionPageVisible('home');
cy.ionNav('ion-item', 'Go to Welcome');
@@ -99,13 +94,7 @@ describe('Nested Outlets 2', () => {
cy.ionPageVisible('item');
});
- it(`/nested-outlet2 >
- Go to list from Home IonItem click >
- Item#1 IonItem Click >
- Item page should be visible >
- Back >
- List page should be visible
-`, () => {
+ it('/nested-outlet2 > Go to list from Home IonItem click > Item#1 IonItem Click > Item page should be visible > Back > List page should be visible', () => {
cy.visit(`http://localhost:${port}/nested-outlet2`);
cy.ionPageVisible('home');
cy.ionNav('ion-item', 'Go to list from Home');
@@ -116,15 +105,7 @@ describe('Nested Outlets 2', () => {
cy.ionPageVisible('list');
});
- it(`/nested-outlet2 >
- Go to list from Home IonItem click >
- Item#1 IonItem Click >
- Item page should be visible >
- Back >
- List page should be visible
- Back >
- Home page should be visible
-`, () => {
+ it('/nested-outlet2 > Go to list from Home IonItem click > Item#1 IonItem Click > Item page should be visible > Back > List page should be visible > Back > Home page should be visible', () => {
cy.visit(`http://localhost:${port}/nested-outlet2`);
cy.ionPageVisible('home');
cy.ionNav('ion-item', 'Go to list from Home');
diff --git a/packages/react-router/test/base/tests/e2e/specs/nested-params.cy.js b/packages/react-router/test/base/tests/e2e/specs/nested-params.cy.js
new file mode 100644
index 00000000000..a52483192fa
--- /dev/null
+++ b/packages/react-router/test/base/tests/e2e/specs/nested-params.cy.js
@@ -0,0 +1,70 @@
+const port = 3000;
+
+describe('Nested Params', () => {
+ /*
+ Tests that route params are correctly passed to nested routes
+ when using parameterized wildcard routes (e.g., user/:userId/*).
+ */
+
+ it('/nested-params > Landing page should be visible', () => {
+ cy.visit(`http://localhost:${port}/nested-params`);
+ cy.ionPageVisible('nested-params-landing');
+ });
+
+ it('/nested-params > Navigate to user details > Params should be available', () => {
+ cy.visit(`http://localhost:${port}/nested-params`);
+ cy.ionPageVisible('nested-params-landing');
+
+ cy.get('#go-to-user-42').click();
+
+ cy.get('[data-testid="user-layout-param"]').should('contain', 'Layout sees user: 42');
+ cy.get('[data-testid="user-details-param"]').should('contain', 'Details view user: 42');
+ });
+
+ it('/nested-params > Navigate between sibling routes > Params should be maintained', () => {
+ cy.visit(`http://localhost:${port}/nested-params`);
+ cy.ionPageVisible('nested-params-landing');
+
+ // Navigate to user 42 details
+ cy.get('#go-to-user-42').click();
+ cy.get('[data-testid="user-layout-param"]').should('contain', 'Layout sees user: 42');
+ cy.get('[data-testid="user-details-param"]').should('contain', 'Details view user: 42');
+
+ // Navigate to settings (sibling route)
+ cy.get('#go-to-settings').click();
+ cy.get('[data-testid="user-layout-param"]').should('contain', 'Layout sees user: 42');
+ cy.get('[data-testid="user-settings-param"]').should('contain', 'Settings view user: 42');
+
+ // Navigate back to details
+ cy.contains('ion-button', 'Back to Details').click();
+ cy.get('[data-testid="user-layout-param"]').should('contain', 'Layout sees user: 42');
+ cy.get('[data-testid="user-details-param"]').should('contain', 'Details view user: 42');
+ });
+
+ it('/nested-params > Direct navigation to nested route > Params should be available', () => {
+ // Navigate directly to a nested route with params
+ cy.visit(`http://localhost:${port}/nested-params/user/123/settings`);
+
+ cy.get('[data-testid="user-layout-param"]').should('contain', 'Layout sees user: 123');
+ cy.get('[data-testid="user-settings-param"]').should('contain', 'Settings view user: 123');
+ });
+
+ it('/nested-params > Different users should have different params', () => {
+ cy.visit(`http://localhost:${port}/nested-params`);
+ cy.ionPageVisible('nested-params-landing');
+
+ // Navigate to user 42
+ cy.get('#go-to-user-42').click();
+ cy.get('[data-testid="user-layout-param"]').should('contain', 'Layout sees user: 42');
+ cy.get('[data-testid="user-details-param"]').should('contain', 'Details view user: 42');
+
+ // Go back to landing
+ cy.go('back');
+ cy.ionPageVisible('nested-params-landing');
+
+ // Navigate to user 99
+ cy.get('#go-to-user-99').click();
+ cy.get('[data-testid="user-layout-param"]').should('contain', 'Layout sees user: 99');
+ cy.get('[data-testid="user-details-param"]').should('contain', 'Details view user: 99');
+ });
+});
diff --git a/packages/react-router/test/base/tests/e2e/specs/routing.cy.js b/packages/react-router/test/base/tests/e2e/specs/routing.cy.js
index fd28ee573c0..b9645ee4917 100644
--- a/packages/react-router/test/base/tests/e2e/specs/routing.cy.js
+++ b/packages/react-router/test/base/tests/e2e/specs/routing.cy.js
@@ -143,6 +143,7 @@ describe('Routing Tests', () => {
it('/ > Menu > Favorites > Menu > Tabs, should be back on Home', () => {
// Tests transferring from one outlet to another and back again via menu
cy.visit(`http://localhost:${port}/routing`);
+ cy.ionPageVisible('home-page');
cy.ionMenuClick();
cy.ionMenuNav('Favorites');
cy.ionPageVisible('favorites-page');
@@ -275,7 +276,7 @@ describe('Routing Tests', () => {
cy.ionMenuClick();
cy.ionMenuNav('Home with redirect');
cy.ionPageVisible('home-page');
- cy.ionPageDoesNotExist('favorites-page');
+ cy.ionPageHidden('favorites-page');
});
it('/routing/tabs/home Menu > Favorites > Menu > Home with router, Home page should be visible, and Favorites should be hidden', () => {
@@ -344,6 +345,16 @@ describe('Routing Tests', () => {
cy.get('div.ion-page[data-pageid=home-details-page-1] [data-testid="details-input"]').should('have.value', '1');
});
+ it('should complete chained Navigate redirects from root to /routing/tabs/home', () => {
+ // Tests that chained Navigate redirects work correctly:
+ // / > click Routing link > /routing (Navigate to tabs) > /routing/tabs (Navigate to home) > /routing/tabs/home
+ // This was a bug where the second Navigate would be unmounted before it could trigger
+ cy.visit(`http://localhost:${port}/`);
+ cy.ionNav('ion-item', 'Routing');
+ cy.ionPageVisible('home-page');
+ cy.url().should('include', '/routing/tabs/home');
+ });
+
/*
Tests to add:
Test that lifecycle events fire
diff --git a/packages/react-router/test/base/tests/e2e/support/commands.js b/packages/react-router/test/base/tests/e2e/support/commands.js
index 947a796157a..ec63d211c45 100644
--- a/packages/react-router/test/base/tests/e2e/support/commands.js
+++ b/packages/react-router/test/base/tests/e2e/support/commands.js
@@ -27,24 +27,24 @@
// Cypress.Commands.overwrite("visit", (originalFn, url, options) => { ... })
Cypress.Commands.add('ionPageVisible', (pageId) => {
- // cy.get(`div.ion-page[data-pageid=${pageId}]`)
- // .should('exist')
- // .should('not.have.class', 'ion-page-hidden')
- // .should('not.have.class', 'ion-page-visible')
+ cy.log(`[ionPageVisible] Checking for visible page: ${pageId}`);
+
+ // First, log all current ion-page elements for debugging
+ cy.get('div.ion-page').then(($pages) => {
+ const pageStates = [];
+ $pages.each((i, el) => {
+ const id = el.getAttribute('data-pageid') || 'unknown';
+ const classes = el.className;
+ const ariaHidden = el.getAttribute('aria-hidden');
+ pageStates.push(`${id}: classes="${classes}" aria-hidden="${ariaHidden}"`);
+ });
+ cy.log(`[ionPageVisible] All ion-page elements: ${pageStates.join(' | ')}`);
+ });
cy.get(`div.ion-page[data-pageid=${pageId}]`)
.should('not.have.class', 'ion-page-hidden')
.should('not.have.class', 'ion-page-invisible')
.should('have.length', 1);
-
- // cy.get(`div.ion-page[data-pageid=${pageId}]`)
- // .should('not.have.class', 'ion-page')
- // .should('have.length', 1)
- // .not('')
- // .should('have.length', 1)
-
- // cy.get(`div.ion-page[data-pageid=${pageId}]`).should('not.have.class', 'ion-page-visible')
- // cy.get(`div.ion-page[data-pageid=${pageId}]`).should('have.attr', 'style', 'z-index: 101;')
});
Cypress.Commands.add('ionPageHidden', (pageId) => {
@@ -82,6 +82,7 @@ Cypress.Commands.add('ionMenuNav', (contains) => {
// cy.get('ion-menu.show-menu').should('exist');
// cy.wait(1000)
cy.contains('ion-item', contains).click({ force: true });
+ cy.wait(250);
// cy.get('div.ion-page').click();
// cy.get('ion-menu').then(menu => {
// cy.wait(1000)
diff --git a/packages/react-router/test/base/tests/e2e/support/index.js b/packages/react-router/test/base/tests/e2e/support/index.js
index 37a498fb5bf..208452ffa62 100644
--- a/packages/react-router/test/base/tests/e2e/support/index.js
+++ b/packages/react-router/test/base/tests/e2e/support/index.js
@@ -18,3 +18,5 @@ import './commands';
// Alternatively you can use CommonJS syntax:
// require('./commands')
+
+require('cypress-terminal-report/src/installLogsCollector')();
diff --git a/packages/react/src/components/IonRoute.tsx b/packages/react/src/components/IonRoute.tsx
index c7dc0b1af40..3bacaac2e1c 100644
--- a/packages/react/src/components/IonRoute.tsx
+++ b/packages/react/src/components/IonRoute.tsx
@@ -4,9 +4,8 @@ import { NavContext } from '../contexts/NavContext';
export interface IonRouteProps {
path?: string;
- exact?: boolean;
show?: boolean;
- render: (props?: any) => JSX.Element; // TODO(FW-2959): type
+ element: React.ReactElement;
disableIonPageManagement?: boolean;
}
diff --git a/packages/react/src/components/IonRouterOutlet.tsx b/packages/react/src/components/IonRouterOutlet.tsx
index d7c75b13189..2fe25df3893 100644
--- a/packages/react/src/components/IonRouterOutlet.tsx
+++ b/packages/react/src/components/IonRouterOutlet.tsx
@@ -3,6 +3,7 @@ import React from 'react';
import { NavContext } from '../contexts/NavContext';
import OutletPageManager from '../routing/OutletPageManager';
+import { generateId } from '../utils/generateId';
import type { IonicReactProps } from './IonicReactProps';
import { IonRouterOutletInner } from './inner-proxies';
@@ -12,6 +13,7 @@ type Props = LocalJSX.IonRouterOutlet & {
basePath?: string;
ref?: React.Ref;
ionPage?: boolean;
+ id?: string;
};
interface InternalProps extends Props {
@@ -23,14 +25,17 @@ interface InternalState {}
class IonRouterOutletContainer extends React.Component {
context!: React.ContextType;
+ private readonly outletId: string;
constructor(props: InternalProps) {
super(props);
+ this.outletId = props.id ?? `routerOutlet-${generateId('routerOutlet')}`;
}
render() {
const StackManager = this.context.getStackManager();
const { children, forwardedRef, ...props } = this.props;
+ const outletId = props.id ?? this.outletId;
return this.context.hasIonicRouter() ? (
props.ionPage ? (
@@ -38,8 +43,8 @@ class IonRouterOutletContainer extends React.Component
) : (
-
-
+
+
{children}
diff --git a/packages/react/src/routing/LocationHistory.ts b/packages/react/src/routing/LocationHistory.ts
index e99e5a88cc9..bd2d3dd24c1 100644
--- a/packages/react/src/routing/LocationHistory.ts
+++ b/packages/react/src/routing/LocationHistory.ts
@@ -91,7 +91,18 @@ export class LocationHistory {
private _replace(routeInfo: RouteInfo) {
const routeInfos = this._getRouteInfosByKey(routeInfo.tab);
routeInfos && routeInfos.pop();
- this.locationHistory.pop();
+
+ // Get the current route that's being replaced
+ const currentRoute = this.locationHistory[this.locationHistory.length - 1];
+
+ // Only pop from global history if we're replacing in the same outlet context.
+ // Don't pop if we're entering a nested outlet (current route has no tab, new route has a tab)
+ const isEnteringNestedOutlet = currentRoute && !currentRoute.tab && !!routeInfo.tab;
+
+ if (!isEnteringNestedOutlet) {
+ this.locationHistory.pop();
+ }
+
this._add(routeInfo);
}
diff --git a/packages/react/src/routing/OutletPageManager.tsx b/packages/react/src/routing/OutletPageManager.tsx
index 0e0d1f9ea08..e970ca67742 100644
--- a/packages/react/src/routing/OutletPageManager.tsx
+++ b/packages/react/src/routing/OutletPageManager.tsx
@@ -12,6 +12,7 @@ interface OutletPageManagerProps {
forwardedRef?: React.ForwardedRef;
routeInfo?: RouteInfo;
StackManager: any; // TODO(FW-2959): type
+ id?: string;
}
export class OutletPageManager extends React.Component {
@@ -83,15 +84,16 @@ export class OutletPageManager extends React.Component {
}
render() {
- const { StackManager, children, routeInfo, ...props } = this.props;
+ const { StackManager, children, routeInfo, id, ...props } = this.props;
return (
{(context) => {
this.ionLifeCycleContext = context;
return (
-
+
(this.ionRouterOutlet = val)}
+ id={id}
{...props}
>
{children}
diff --git a/packages/react/src/routing/RouteManagerContext.ts b/packages/react/src/routing/RouteManagerContext.ts
index 32cfa6c50f8..ba8306304af 100644
--- a/packages/react/src/routing/RouteManagerContext.ts
+++ b/packages/react/src/routing/RouteManagerContext.ts
@@ -23,6 +23,11 @@ export interface RouteManagerContextState {
routeInfo: RouteInfo,
reRender: () => void
) => React.ReactNode[];
+ /**
+ * Returns all view items currently registered for a given outlet id.
+ * Used by StackManager for out-of-scope cleanup.
+ */
+ getViewItemsForOutlet: (outletId: string) => ViewItem[];
goBack: () => void;
unMountViewItem: (viewItem: ViewItem) => void;
}
@@ -37,6 +42,7 @@ export const RouteManagerContext = /*@__PURE__*/ React.createContext undefined,
findViewItemByRouteInfo: () => undefined,
getChildrenToRender: () => undefined as any,
+ getViewItemsForOutlet: () => [] as any,
goBack: () => undefined,
unMountViewItem: () => undefined,
});
diff --git a/packages/react/test/apps/react17/package.json b/packages/react/test/apps/react17/package.json
index 68079579bba..f05a10f55de 100644
--- a/packages/react/test/apps/react17/package.json
+++ b/packages/react/test/apps/react17/package.json
@@ -2,18 +2,22 @@
"name": "test-app",
"version": "0.0.1",
"private": true,
+ "overrides": {
+ "@ionic/react-router": {
+ "react-router": "$react-router",
+ "react-router-dom": "$react-router-dom"
+ }
+ },
"dependencies": {
"@ionic/react": "^6.6.1",
"@ionic/react-router": "^6.6.1",
"@types/react": "^17.0.53",
"@types/react-dom": "^17.0.19",
- "@types/react-router": "^5.1.20",
- "@types/react-router-dom": "^5.3.3",
"ionicons": "^8.0.13",
"react": "^17.0.2",
"react-dom": "^17.0.2",
- "react-router": "^5.3.4",
- "react-router-dom": "^5.3.4",
+ "react-router": "^6.0.0",
+ "react-router-dom": "^6.0.0",
"react-scripts": "^5.0.0",
"typescript": "^4.1.3"
},
diff --git a/packages/react/test/apps/react18/package.json b/packages/react/test/apps/react18/package.json
index 65ab9e00239..1c401656216 100644
--- a/packages/react/test/apps/react18/package.json
+++ b/packages/react/test/apps/react18/package.json
@@ -2,14 +2,20 @@
"name": "test-app",
"version": "0.0.1",
"private": true,
+ "overrides": {
+ "@ionic/react-router": {
+ "react-router": "$react-router",
+ "react-router-dom": "$react-router-dom"
+ }
+ },
"dependencies": {
"@ionic/react": "^7.0.0",
"@ionic/react-router": "^7.0.0",
"ionicons": "^8.0.13",
"react": "^18.2.0",
"react-dom": "^18.2.0",
- "react-router": "^5.3.4",
- "react-router-dom": "^5.3.4"
+ "react-router": "^6.0.0",
+ "react-router-dom": "^6.0.0"
},
"scripts": {
"dev": "vite",
@@ -27,9 +33,6 @@
"@testing-library/user-event": "^14.4.3",
"@types/react": "^18.0.27",
"@types/react-dom": "^18.0.10",
- "@types/react-router": "^5.1.20",
- "@types/react-router-dom": "^5.3.3",
- "@vitejs/plugin-legacy": "^4.0.2",
"@vitejs/plugin-react": "^4.0.1",
"concurrently": "^6.3.0",
"cypress": "^13.2.0",
diff --git a/packages/react/test/apps/react18/vite.config.ts b/packages/react/test/apps/react18/vite.config.ts
index 20e6c2a8071..a5f03b990f4 100644
--- a/packages/react/test/apps/react18/vite.config.ts
+++ b/packages/react/test/apps/react18/vite.config.ts
@@ -1,4 +1,3 @@
-import legacy from '@vitejs/plugin-legacy'
import react from '@vitejs/plugin-react'
import { defineConfig } from 'vite'
@@ -6,7 +5,6 @@ import { defineConfig } from 'vite'
export default defineConfig({
plugins: [
react(),
- legacy()
],
test: {
globals: true,
diff --git a/packages/react/test/apps/react19/package.json b/packages/react/test/apps/react19/package.json
index 67e3bf1db83..028e792fe9d 100644
--- a/packages/react/test/apps/react19/package.json
+++ b/packages/react/test/apps/react19/package.json
@@ -2,14 +2,20 @@
"name": "test-app",
"version": "0.0.1",
"private": true,
+ "overrides": {
+ "@ionic/react-router": {
+ "react-router": "$react-router",
+ "react-router-dom": "$react-router-dom"
+ }
+ },
"dependencies": {
"@ionic/react": "^8.4.0",
"@ionic/react-router": "^8.4.0",
"ionicons": "^8.0.13",
"react": "19.0.0",
"react-dom": "19.0.0",
- "react-router": "^5.3.4",
- "react-router-dom": "^5.3.4"
+ "react-router": "^6.0.0",
+ "react-router-dom": "^6.0.0"
},
"scripts": {
"dev": "vite",
@@ -22,15 +28,15 @@
"e2e": "concurrently \"serve -s dist -l 3000\" \"wait-on http-get://localhost:3000 && npm run cypress\" --kill-others --success first"
},
"devDependencies": {
+ "@testing-library/dom": "^10.0.0",
"@testing-library/jest-dom": "^5.16.5",
"@testing-library/react": "^16.2.0",
"@testing-library/user-event": "^14.4.3",
"@types/react": "19.0.10",
"@types/react-dom": "19.0.4",
- "@types/react-router": "^5.1.20",
- "@types/react-router-dom": "^5.3.3",
"@vitejs/plugin-legacy": "^4.0.2",
"@vitejs/plugin-react": "^4.0.1",
+ "terser": "^5.16.0",
"concurrently": "^6.3.0",
"cypress": "^13.2.0",
"eslint": "^8.35.0",
diff --git a/packages/react/test/base/scripts/sync.sh b/packages/react/test/base/scripts/sync.sh
index a6b441d8b5c..0626fbd8f00 100755
--- a/packages/react/test/base/scripts/sync.sh
+++ b/packages/react/test/base/scripts/sync.sh
@@ -15,4 +15,4 @@ npm pack ../../../
npm pack ../../../../react-router
# Install Dependencies
-npm install *.tgz --no-save
+npm install *.tgz --no-save --legacy-peer-deps
diff --git a/packages/react/test/base/src/App.tsx b/packages/react/test/base/src/App.tsx
index 634af89f075..860c8c69316 100644
--- a/packages/react/test/base/src/App.tsx
+++ b/packages/react/test/base/src/App.tsx
@@ -1,7 +1,7 @@
import { IonApp, IonRouterOutlet, setupIonicReact } from '@ionic/react';
import { IonReactRouter } from '@ionic/react-router';
import React from 'react';
-import { Route } from 'react-router-dom';
+import { Route } from 'react-router';
/* Core CSS required for Ionic components to work properly */
import '@ionic/react/css/core.css';
@@ -45,32 +45,32 @@ const App: React.FC = () => (
-
-
-
-
+ } />
+ } />
+ } />
+ } />
}
/>
-
+ } />
}
/>
}
/>
-
-
-
-
-
-
-
-
-
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
diff --git a/packages/react/test/base/src/pages/Tabs.tsx b/packages/react/test/base/src/pages/Tabs.tsx
index 2098bfcb266..89c82fd2c53 100644
--- a/packages/react/test/base/src/pages/Tabs.tsx
+++ b/packages/react/test/base/src/pages/Tabs.tsx
@@ -1,6 +1,6 @@
import React from 'react';
import { IonLabel, IonRouterOutlet, IonTabBar, IonTabButton, IonTabs, IonPage } from '@ionic/react';
-import { Route, Redirect } from 'react-router';
+import { Route, Navigate } from 'react-router';
interface TabsProps {}
@@ -9,8 +9,8 @@ const Tabs: React.FC = () => {
-
- Tab 1} />
+ } />
+ Tab 1} />
window.alert('Tab was clicked')}>
diff --git a/packages/react/test/base/src/pages/TabsDirectNavigation.tsx b/packages/react/test/base/src/pages/TabsDirectNavigation.tsx
index 2e412e174ab..c02589ad6c6 100644
--- a/packages/react/test/base/src/pages/TabsDirectNavigation.tsx
+++ b/packages/react/test/base/src/pages/TabsDirectNavigation.tsx
@@ -1,7 +1,7 @@
import { IonContent, IonHeader, IonIcon, IonLabel, IonPage, IonRouterOutlet, IonTabBar, IonTabButton, IonTabs, IonTitle, IonToolbar } from '@ionic/react';
import { homeOutline, radioOutline, libraryOutline, searchOutline } from 'ionicons/icons';
import React from 'react';
-import { Route, Redirect } from 'react-router-dom';
+import { Route, Navigate } from 'react-router';
const HomePage: React.FC = () => (
@@ -59,11 +59,11 @@ const TabsDirectNavigation: React.FC = () => {
return (
-
- } exact={true} />
- } exact={true} />
- } exact={true} />
- } exact={true} />
+ } />
+ } />
+ } />
+ } />
+ } />
diff --git a/packages/react/test/base/src/pages/overlay-components/OverlayComponents.tsx b/packages/react/test/base/src/pages/overlay-components/OverlayComponents.tsx
index 19aebc9081c..29b4363a7aa 100644
--- a/packages/react/test/base/src/pages/overlay-components/OverlayComponents.tsx
+++ b/packages/react/test/base/src/pages/overlay-components/OverlayComponents.tsx
@@ -1,6 +1,6 @@
import React from 'react';
import { IonIcon, IonLabel, IonRouterOutlet, IonTabBar, IonTabButton, IonTabs } from '@ionic/react';
-import { Route, Redirect } from 'react-router';
+import { Route, Navigate } from 'react-router';
import {
addCircleOutline,
alarm,
@@ -26,16 +26,16 @@ const OverlayHooks: React.FC = () => {
return (
-
-
-
-
-
-
-
-
-
-
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
diff --git a/packages/react/test/base/src/pages/overlay-hooks/OverlayHooks.tsx b/packages/react/test/base/src/pages/overlay-hooks/OverlayHooks.tsx
index 4cc9bf88ddd..8036c0efbee 100644
--- a/packages/react/test/base/src/pages/overlay-hooks/OverlayHooks.tsx
+++ b/packages/react/test/base/src/pages/overlay-hooks/OverlayHooks.tsx
@@ -1,6 +1,6 @@
import React from 'react';
import { IonIcon, IonLabel, IonRouterOutlet, IonTabBar, IonTabButton, IonTabs } from '@ionic/react';
-import { Route, Redirect } from 'react-router';
+import { Route, Navigate } from 'react-router';
import ActionSheetHook from './ActionSheetHook';
import {
addCircleOutline,
@@ -24,14 +24,14 @@ const OverlayHooks: React.FC = () => {
return (
-
-
-
-
-
-
-
-
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />