Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/actions/build-react-router/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ runs:
shell: bash
working-directory: ./packages/react-router
# The rollup build reports type errors as warnings and still succeeds, so
# this step is what keeps the package type-clean.
# this step keeps the package type-clean on both React Router majors.
- name: 🔎 Typecheck
run: npm run typecheck
shell: bash
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -183,7 +183,7 @@ jobs:
strategy:
fail-fast: false
matrix:
apps: [reactrouter6-react18, reactrouter6-react19]
apps: [reactrouter6-react18, reactrouter6-react19, reactrouter7-react19]
needs: [build-react, build-react-router]
runs-on: ubuntu-latest
steps:
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/stencil-nightly.yml
Original file line number Diff line number Diff line change
Expand Up @@ -197,7 +197,7 @@ jobs:
strategy:
fail-fast: false
matrix:
apps: [reactrouter6-react18, reactrouter6-react19]
apps: [reactrouter6-react18, reactrouter6-react19, reactrouter7-react19]
needs: [build-react, build-react-router]
runs-on: ubuntu-latest
steps:
Expand Down
3 changes: 2 additions & 1 deletion core/scripts/vercel-build.sh
Original file line number Diff line number Diff line change
Expand Up @@ -268,7 +268,8 @@ build_vue_test() {

build_react_router_test() {
local APP
APP=$(pick_app "${REPO_ROOT}/packages/react-router/test") || {
# React Router 6 apps only, since reactrouter7-* sorts above them.
APP=$(pick_app "${REPO_ROOT}/packages/react-router/test" '^reactrouter6-') || {
echo "[react-router] No test app found, skipping."
return 0
}
Expand Down
22 changes: 22 additions & 0 deletions docs/react-router/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,28 @@

The [@ionic/react-router](https://www.npmjs.com/package/@ionic/react-router) package is the routing integration for [@ionic/react](https://www.npmjs.com/package/@ionic/react). It uses the [React Router](https://github.com/remix-run/react-router) library beneath the surface.

## React Router 7 Transitions

React Router 7 wraps router state updates in `React.startTransition` by default. React Router 6 only does so behind `future={{ v7_startTransition: true }}`.

Under a transition React can defer the commit, so Ionic's page-visible signal can fire before the incoming page's DOM exists, which shows up as intermittent navigation failures. Ionic's `IonReactRouter`, `IonReactHashRouter` and `IonReactMemoryRouter` default `useTransitions` to `false`, so v7 navigation behaves the way v6 always has.

Passing `useTransitions` opts back in. That's React Router's explicit `useTransitions={true}` mode, which also wraps `<Link>` and `<Form>` navigations, so it does more than plain React Router 7's default:

```tsx
<IonReactRouter useTransitions>{/* ... */}</IonReactRouter>
```

The prop only exists under this name from 7.15.0. Earlier 7.x releases either have no opt-out (7.0 through 7.9) or call it `unstable_useTransitions` (7.10 through 7.14), so the supported range starts at 7.15.

A React Router 6 app that sets `future={{ v7_startTransition: true }}` opts into the same wrapping through a different key, which Ionic doesn't override.

## Relative Paths Under Splat Routes

React Router 7 resolves a relative `to` inside a splat route (`path="files/*"`) against the full matched URL, tail included. React Router 6 only does that under `future={{ v7_relativeSplatPath: true }}`.

Ionic always resolves against the splat's base, even with that flag set, so `<Link to="edit">` at `/files/docs/readme` goes to `/files/edit`. Including the tail would make an index `<Navigate to="home" />` under a splat redirect in a loop, because Ionic keeps the redirecting view mounted briefly after it leaves.

## Contributing

See our [Contributing Guide](/docs/CONTRIBUTING.md).
Expand Down
32 changes: 27 additions & 5 deletions docs/react-router/testing.md
Original file line number Diff line number Diff line change
@@ -1,11 +1,32 @@
# React Router Testing

Ionic Framework supports React Router v6 across multiple versions of React. As a result, we need to verify that Ionic routing works correctly with each of these React versions.
Ionic Framework supports React Router 6.4+ and 7.15+. The test apps below run Ionic routing against both React Router majors and React 18 and 19.

| App | React Router | React | Notes |
| --- | --- | --- | --- |
| `reactrouter6-react18` | 6 | 18 | Default for `test_runner.sh` |
| `reactrouter6-react19` | 6 | 19 | Pinned to React 19.0.0 |
| `reactrouter7-react19` | 7.15+ | 19 | Pinned to React 19.0.0 |

The apps don't depend on `@ionic/react` or `@ionic/react-router` directly or carry `overrides`, so `npm run sync` checks the peer ranges the package actually ships. Don't add them back, since a registry dependency needs an override that masks those ranges.

## Type Checking

Run `npm run typecheck` in `packages/react-router` to check types. The rollup build only reports type errors as warnings, so a passing build does not mean the types are clean.

The `typecheck` script runs against both supported React Router majors and fails if either does:

| Script | Config | Resolves `react-router-dom` to |
| --- | --- | --- |
| `typecheck.rr6` | `tsconfig.json` | `devDependencies`' `react-router-dom`, currently 6.x |
| `typecheck.rr7` | `tsconfig.rr7.json` | the `react-router-dom-v7` npm alias, currently 7.x |

Only one copy of a package name can live in `node_modules`, so React Router 7 is installed under the npm alias `react-router-dom-v7` and `tsconfig.rr7.json` points `react-router-dom` at it with a `paths` entry. The alias has its own `react-router` nested underneath, which is what React Router 7's `export * from 'react-router'` resolves to. A direct `react-router` import from `src/` skips the mapping and still resolves to 6.x, so an ESLint `no-restricted-imports` rule bans it.

Both majors need checking because the router props differ, `future` on 6 and `useTransitions` on 7, so code that reads either one compiles on its own major and breaks on the other. That's also why `type-tests/rr7-transition-prop.ts` only runs in the React Router 7 lane.

To bump the React Router 7 version under test, run `npm install --save-dev "react-router-dom-v7@npm:react-router-dom@^X.Y.Z"`.

## Syncing Local Changes

The React test app supports syncing your locally built changes for validation.
Expand All @@ -20,7 +41,7 @@ From here you can either build the application or start a local dev server. When

## Running the Test Suites

`packages/react-router/scripts/test_runner.sh` orchestrates the React Router test suites end to end: it builds `@ionic/core`, `@ionic/react`, and `@ionic/react-router`, builds the test app, syncs local packages, starts the dev server, and runs Cypress and Playwright in sequence. By default it uses the `reactrouter6-react18` app; pass `--app reactrouter6-react19` to test against the latest supported React version.
`packages/react-router/scripts/test_runner.sh` orchestrates the React Router test suites end to end: it builds `@ionic/core`, `@ionic/react`, and `@ionic/react-router`, builds the test app, syncs local packages, starts the dev server, and runs Cypress and Playwright in sequence. By default it uses the `reactrouter6-react18` app. Pass `--app` with any name from the table above (for example `--app reactrouter7-react19`) to test a different combination.

```shell
# Full run (build + Cypress + Playwright)
Expand All @@ -43,7 +64,7 @@ Useful flags:
| `--skip-build` | Reuse existing `packages/react-router/test/build/<app>/` instead of rebuilding |
| `--playwright-only` | Run only the Playwright e2e suite |
| `--spec <pattern>` | Filter Playwright specs by file path |
| `--app <name>` | Pick a different app variant from `packages/react-router/test/apps/` (default: `reactrouter6-react18`; use `reactrouter6-react19` for the latest supported React version) |
| `--app <name>` | Pick a different app variant from `packages/react-router/test/apps/` (default `reactrouter6-react18`, or `reactrouter7-react19` to test React Router 7) |
| `--serve` | Start the dev server only and open the browser |

## Debug Logging in E2E Runs
Expand Down Expand Up @@ -92,5 +113,6 @@ As we add support for new versions of React, we will also need to update this di
2. Update the application to the latest version of React.
3. Make note of any files that changed during the upgrade (`package.json`, `package-lock.json`, etc).
4. Copy the changed files to a new directory in `apps`.
5. Add a new entry to the matrix for `test-react-router-e2e` in `./github/workflows/build.yml`. This will allow the new test app to run against all PRs.
6. Commit these changes and push.
5. Add a new entry to the `test-react-router-e2e` matrix in both `.github/workflows/build.yml` and `.github/workflows/stencil-nightly.yml`, since the nightly workflow keeps its own copy of the matrix.
6. The Vercel preview (`build_react_router_test` in `core/scripts/vercel-build.sh`) takes the highest `reactrouter6-*` app, so a new React Router 6 app becomes the preview automatically. A new React Router major only does if you widen that `pick_app` filter.
7. Commit these changes and push.
27 changes: 26 additions & 1 deletion packages/react-router/eslint.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,19 @@ const compat = new FlatCompat({
recommendedConfig: js.configs.recommended,
});

// The React Router 7 typecheck only remaps `react-router-dom`, so a `react-router` import would skip it.
const noReactRouterImport = [
'error',
{
patterns: [
{
group: ['react-router', 'react-router/*'],
message: 'Import from react-router-dom instead, so tsconfig.rr7.json remaps it to React Router 7.',
},
],
},
];

/*
The shared @ionic/eslint-config is still authored in eslintrc format, so the
previous config is bridged through FlatCompat. Everything is scoped to TS
Expand Down Expand Up @@ -36,7 +49,19 @@ module.exports = [
'@typescript-eslint/no-non-null-assertion': 'off',
'@typescript-eslint/prefer-optional-chain': 'off',
'@typescript-eslint/no-empty-object-type': 'off',
'no-restricted-imports': noReactRouterImport,
},
})
.map((config) => ({ ...config, files: ['**/*.ts', '**/*.tsx'] })),
.map((config) => ({ ...config, files: ['src/**/*.ts', 'src/**/*.tsx'] })),
// The type-aware config above can't parse `type-tests/`, which only compiles under `tsconfig.rr7.json`.
{
files: ['type-tests/**/*.ts', 'type-tests/**/*.tsx'],
languageOptions: {
parser: require('@typescript-eslint/parser'),
sourceType: 'module',
},
rules: {
'no-restricted-imports': noReactRouterImport,
},
},
];
117 changes: 82 additions & 35 deletions packages/react-router/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading
Loading