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 ( - - - - - - - - + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } />