diff --git a/Makefile b/Makefile index 2eecf2b0388..6be609614c6 100644 --- a/Makefile +++ b/Makefile @@ -48,6 +48,10 @@ build-offline: ## build the offline example preview-offline: ## preview the offline example @yarn preview-offline +build-ra-router-react-router: + @echo "Transpiling ra-router-react-router files..."; + @cd ./packages/ra-router-react-router && yarn build + build-ra-core: @echo "Transpiling ra-core files..."; @cd ./packages/ra-core && yarn build @@ -56,6 +60,10 @@ build-ra-router-tanstack: @echo "Transpiling ra-router-tanstack files..."; @cd ./packages/ra-router-tanstack && yarn build +build-ra-router-react-router-next: + @echo "Transpiling ra-router-react-router-next files..."; + @cd ./packages/ra-router-react-router-next && yarn build + build-ra-ui-materialui: @echo "Transpiling ra-ui-materialui files..."; @cd ./packages/ra-ui-materialui && yarn build @@ -129,7 +137,7 @@ update-package-exports: ## Update the package.json "exports" field for all packa @echo "Updating package exports..." @yarn tsx ./scripts/update-package-exports.ts -build: build-ra-core build-ra-router-tanstack build-ra-data-fakerest build-ra-ui-materialui build-ra-data-json-server build-ra-data-local-forage build-ra-data-local-storage build-ra-data-simple-rest build-ra-data-graphql build-ra-data-graphql-simple build-ra-input-rich-text build-data-generator build-ra-language-english build-ra-language-french build-ra-i18n-i18next build-ra-i18n-polyglot build-react-admin build-ra-no-code build-create-react-admin update-package-exports ## compile ES6 files to JS +build: build-ra-router-react-router build-ra-core build-ra-router-tanstack build-ra-router-react-router-next build-ra-data-fakerest build-ra-ui-materialui build-ra-data-json-server build-ra-data-local-forage build-ra-data-local-storage build-ra-data-simple-rest build-ra-data-graphql build-ra-data-graphql-simple build-ra-input-rich-text build-data-generator build-ra-language-english build-ra-language-french build-ra-i18n-i18next build-ra-i18n-polyglot build-react-admin build-ra-no-code build-create-react-admin update-package-exports ## compile ES6 files to JS typecheck: ## check TypeScript types @yarn typecheck diff --git a/docs/Admin.md b/docs/Admin.md index 2e14aefbeb2..5f8c81651d1 100644 --- a/docs/Admin.md +++ b/docs/Admin.md @@ -40,7 +40,7 @@ In most apps, you need to pass more props to ``. Here is a more complete ```tsx // in src/App.js import { Admin, Resource, CustomRoutes } from 'react-admin'; -import { Route } from "react-router-dom"; +import { Route } from "react-router"; import { dataProvider, authProvider, i18nProvider } from './providers'; import { Layout } from './layout'; @@ -86,7 +86,7 @@ To make the main app component more concise, a good practice is to move the reso ```tsx // in src/App.js import { Admin, Resource, CustomRoutes } from 'react-admin'; -import { Route } from "react-router-dom"; +import { Route } from "react-router"; import { dataProvider, authProvider, i18nProvider } from './providers'; import { Layout } from './layout'; @@ -396,7 +396,8 @@ The Auth Provider also lets you configure redirections after login/logout, anony Use this prop to make all routes and links in your Admin relative to a "base" portion of the URL pathname that they all share. This is required when using the [`BrowserRouter`](https://reactrouter.com/en/main/router-components/browser-router) to serve the application under a sub-path of your domain (for example https://marmelab.com/ra-enterprise-demo), or when embedding react-admin inside a single-page app with its own routing. ```tsx -import { BrowserRouter, Routes, Route } from 'react-router-dom'; +import { Routes, Route } from 'react-router'; +import { BrowserRouter } from 'react-router-dom'; import { StoreFront } from './StoreFront'; import { StoreAdmin } from './StoreAdmin'; @@ -1158,7 +1159,7 @@ In addition to [` elements`](./Resource.md) for CRUD pages, you can us ```tsx // in src/App.js import * as React from "react"; -import { Route } from 'react-router-dom'; +import { Route } from 'react-router'; import { Admin, Resource, CustomRoutes } from 'react-admin'; import posts from './posts'; import comments from './comments'; @@ -1190,7 +1191,8 @@ By default, react-admin uses react-router with a [HashRouter](https://reactroute But you may want to use another routing strategy, e.g. to allow server-side rendering of individual pages. React-router offers various Router components to implement such routing strategies. If you want to use a different router, simply put your app in a create router function. React-admin will detect that it's already inside a router, and skip its own router. ```tsx -import { RouterProvider, createBrowserRouter } from 'react-router-dom'; +import { RouterProvider } from 'react-router'; +import { createBrowserRouter } from 'react-router-dom'; import { Admin, Resource } from 'react-admin'; import { dataProvider } from './dataProvider'; @@ -1217,7 +1219,8 @@ However, if you serve your admin from a sub path AND use another Router (like [` ```tsx import { Admin, Resource } from 'react-admin'; -import { RouterProvider, createBrowserRouter } from 'react-router-dom'; +import { RouterProvider } from 'react-router'; +import { createBrowserRouter } from 'react-router-dom'; import { dataProvider } from './dataProvider'; const App = () => { @@ -1249,7 +1252,8 @@ If you want to use react-admin as a sub path of a larger React application, chec You can include a react-admin app inside another app, using a react-router ``: ```tsx -import { RouterProvider, Routes, Route, createBrowserRouter } from 'react-router-dom'; +import { RouterProvider, Routes, Route } from 'react-router'; +import { createBrowserRouter } from 'react-router-dom'; import { StoreFront } from './StoreFront'; import { StoreAdmin } from './StoreAdmin'; diff --git a/docs/Architecture.md b/docs/Architecture.md index 512299e715e..a34e23bcc0a 100644 --- a/docs/Architecture.md +++ b/docs/Architecture.md @@ -21,7 +21,7 @@ For example, the following react-admin application: ```jsx import { Admin, Resource, CustomRoutes } from 'react-admin'; -import { Route } from 'react-router-dom'; +import { Route } from 'react-router'; export const App = () => ( diff --git a/docs/Authenticated.md b/docs/Authenticated.md index e1ba9831859..3d569766985 100644 --- a/docs/Authenticated.md +++ b/docs/Authenticated.md @@ -13,7 +13,7 @@ Use it as an alternative to the [`useAuthenticated()`](./useAuthenticated.md) ho ```jsx import { Admin, CustomRoutes, Authenticated } from 'react-admin'; -import { Route } from 'react-router-dom'; +import { Route } from 'react-router'; const App = () => ( diff --git a/docs/Authentication.md b/docs/Authentication.md index 7bd72157170..81206a99459 100644 --- a/docs/Authentication.md +++ b/docs/Authentication.md @@ -95,7 +95,7 @@ When you add custom pages, they are accessible to anonymous users by default. To ```jsx import { Admin, CustomRoutes, useAuthenticated } from 'react-admin'; -import { Route } from 'react-router-dom'; +import { Route } from 'react-router'; const RestrictedPage = () => { const { isPending } = useAuthenticated(); // redirects to login if not authenticated @@ -127,7 +127,7 @@ Alternatively, use the [`` component](./Authenticated.md) to disp ```jsx import { Admin, CustomRoutes, Authenticated } from 'react-admin'; -import { Route } from 'react-router-dom'; +import { Route } from 'react-router'; const RestrictedPage = () => ( diff --git a/docs/Breadcrumb.md b/docs/Breadcrumb.md index 1d6f985bee5..5c47c3f12f3 100644 --- a/docs/Breadcrumb.md +++ b/docs/Breadcrumb.md @@ -744,7 +744,7 @@ Let's say that this custom page is added to the app under the `/settings` URL: ```jsx // in src/App.jsx import { Admin, Resource, CustomRoutes, } from 'react-admin'; -import { Route } from 'react-router-dom'; +import { Route } from 'react-router'; import { MyLayout } from './MyLayout'; import { UserPreferences } from './UserPreferences'; @@ -854,7 +854,7 @@ For instance, the screencast at the top of this page shows a `songs` resource ne ```jsx import { Admin, Resource } from 'react-admin'; -import { Route } from 'react-router-dom'; +import { Route } from 'react-router'; export const App = () => ( diff --git a/docs/CanAccess.md b/docs/CanAccess.md index d9ee8098bda..22ebac43804 100644 --- a/docs/CanAccess.md +++ b/docs/CanAccess.md @@ -63,7 +63,7 @@ Use the [``](./CustomRoutes.md) component to add custom routes to ```tsx import { Admin, CustomRoutes, Authenticated, CanAccess, AccessDenied, Layout } from 'react-admin'; -import { Route } from 'react-router-dom'; +import { Route } from 'react-router'; import { LogsPage } from './LogsPage'; import { MyMenu } from './MyMenu'; diff --git a/docs/ContainerLayout.md b/docs/ContainerLayout.md index 86d313d8720..3c1ca863777 100644 --- a/docs/ContainerLayout.md +++ b/docs/ContainerLayout.md @@ -97,7 +97,7 @@ import { ListGuesser, EditGuesser, } from 'react-admin'; -import { Route } from 'react-router-dom'; +import { Route } from 'react-router'; import { ContainerLayout, HorizontalMenu, diff --git a/docs/CustomRoutes.md b/docs/CustomRoutes.md index baee4cf530e..7d0aa2fc4d4 100644 --- a/docs/CustomRoutes.md +++ b/docs/CustomRoutes.md @@ -43,7 +43,7 @@ The `Route` element depends on the routing library you use (e.g. `react-router` ```jsx // for react-router -import { Route } from 'react-router-dom'; +import { Route } from 'react-router'; // for tanstack-router import { tanStackRouterProvider } from 'ra-router-tanstack'; const { Route } = tanStackRouterProvider; @@ -62,7 +62,7 @@ Now, when a user browses to `/settings` or `/profile`, the components you define ```jsx // in src/App.js import { Admin, Resource, CustomRoutes } from 'react-admin'; -import { Route } from "react-router-dom"; +import { Route } from "react-router"; import { dataProvider } from './dataProvider'; import { Settings } from './Settings'; @@ -95,7 +95,7 @@ Here is an example of application configuration mixing custom routes with and wi ```jsx // in src/App.js import { Admin, CustomRoutes } from 'react-admin'; -import { Route } from "react-router-dom"; +import { Route } from "react-router"; import { dataProvider } from './dataProvider'; import { Register } from './Register'; @@ -124,7 +124,7 @@ By default, custom routes can be accessed even by anomymous users. If you want t ```jsx // in src/App.js import { Admin, CustomRoutes, Authenticated } from 'react-admin'; -import { Route } from "react-router-dom"; +import { Route } from "react-router"; import { dataProvider } from './dataProvider'; import { Settings } from './Settings'; @@ -207,7 +207,7 @@ Finally, pass the custom `` component to ``: ```jsx // in src/App.js import { Admin, Resource, CustomRoutes } from 'react-admin'; -import { Route } from "react-router-dom"; +import { Route } from "react-router"; import { dataProvider } from './dataProvider'; import { MyLayout } from './MyLayout'; @@ -276,7 +276,7 @@ To do so, add the `` elements as [children of the `` element](. ```jsx import { Admin, Resource } from 'react-admin'; -import { Route } from "react-router-dom"; +import { Route } from "react-router"; import { dataProvider } from './dataProvider'; import posts from './posts'; @@ -307,7 +307,7 @@ This is usually useful for nested resources, such as books on authors: ```jsx // in src/App.jsx import { Admin, Resource, ListGuesser, EditGuesser } from 'react-admin'; -import { Route } from "react-router-dom"; +import { Route } from "react-router"; const App = () => ( diff --git a/docs/DeletedRecordsList.md b/docs/DeletedRecordsList.md index 589c862d32e..a7e8748a860 100644 --- a/docs/DeletedRecordsList.md +++ b/docs/DeletedRecordsList.md @@ -19,7 +19,7 @@ However, you need to define the route to reach this component manually using [`< ```tsx // in src/App.js import { Admin, CustomRoutes } from 'react-admin'; -import { Route } from 'react-router-dom'; +import { Route } from 'react-router'; import { DeletedRecordsList } from '@react-admin/ra-soft-delete'; export const App = () => ( @@ -116,7 +116,7 @@ However, you **must** use [``](./ShowDeleted.md) component instead {% raw %} ```tsx import { Admin, CustomRoutes, SimpleShowLayout, TextField } from 'react-admin'; -import { Route } from 'react-router-dom'; +import { Route } from 'react-router'; import { DeletedRecordsList, ShowDeleted } from '@react-admin/ra-soft-delete'; const ShowDeletedBook = () => ( @@ -394,7 +394,7 @@ In the example below, the deleted records lists store their list parameters sepa {% raw %} ```tsx import { Admin, CustomRoutes } from 'react-admin'; -import { Route } from 'react-router-dom'; +import { Route } from 'react-router'; import { DeletedRecordsList } from '@react-admin/ra-soft-delete'; const Admin = () => { diff --git a/docs/List.md b/docs/List.md index 042f58f15a2..a344ff97549 100644 --- a/docs/List.md +++ b/docs/List.md @@ -1048,7 +1048,7 @@ import { List, DataTable, } from 'react-admin'; -import { Route } from 'react-router-dom'; +import { Route } from 'react-router'; const NewerBooks = () => ( `](./CustomRoutes.md) component to add custom routes to ```tsx import { Admin, CustomRoutes, Authenticated, CanAccess, AccessDenied, Layout } from 'react-admin'; -import { Route } from 'react-router-dom'; +import { Route } from 'react-router'; import { LogsPage } from './LogsPage'; import { MyMenu } from './MyMenu'; diff --git a/docs/ReactRouterV8.md b/docs/ReactRouterV8.md new file mode 100644 index 00000000000..3ba4b8c49c1 --- /dev/null +++ b/docs/ReactRouterV8.md @@ -0,0 +1,59 @@ +--- +layout: default +title: "React Router v8 Integration" +--- + +# React Router v8 Integration + +By default, react-admin is powered by [React Router](https://reactrouter.com/) v6/v7 through the `ra-router-react-router` adapter, which is installed automatically. React Router v8 merged the former `react-router-dom` package into `react-router` and requires **React 19**, so support for it ships as a separate, opt-in adapter package: `ra-router-react-router-next`. + +**New projects are encouraged to run on React Router v8 using `ra-router-react-router-next`.** The React Router v6/v7 adapter remains the default for backward compatibility, and **`ra-router-react-router-next` will become the default `ra-router-react-router` in react-admin v6.** + +Use this package when your application runs on React Router v8. + +## Installation + +```bash +npm install ra-router-react-router-next react-router@^8 +# or +yarn add ra-router-react-router-next react-router@^8 +``` + +React Router v8 requires React 19. Make sure your application uses `react@^19.2.7` and `react-dom@^19.2.7`. + +## Configuration + +Set the `` to `reactRouterNextProvider`: + +```tsx +import { Admin, Resource, ListGuesser } from 'react-admin'; +import { reactRouterNextProvider } from 'ra-router-react-router-next'; +import { dataProvider } from './dataProvider'; + +const App = () => ( + + + + +); + +export default App; +``` + +That's it! React-admin will now use React Router v8 for all routing operations. + +## Standalone Mode + +When using `reactRouterNextProvider` without an existing React Router, react-admin creates its own hash router automatically (URLs like `/#/posts`). This is called **standalone mode**. This is the simplest setup and requires no additional configuration. + +## Embedded Mode + +If your application already uses React Router, you can embed react-admin inside it. React-admin detects the existing router context and uses it instead of creating its own. + +## When To Use This Package + +- Use the **default** `ra-router-react-router` adapter (installed automatically, no extra setup) if your app runs on React Router v6 or v7. +- Use **`ra-router-react-router-next`** if your app runs on React Router v8 (and therefore React 19). This is the recommended choice for new projects, and it will become the default in react-admin v6. diff --git a/docs/Resource.md b/docs/Resource.md index a4565854c51..be0a830e5ab 100644 --- a/docs/Resource.md +++ b/docs/Resource.md @@ -116,7 +116,7 @@ For instance, the following code creates an `authors` resource, and adds an `/au ```jsx // in src/App.jsx import { Admin, Resource } from 'react-admin'; -import { Route } from 'react-router-dom'; +import { Route } from 'react-router'; import { AuthorList } from './AuthorList'; import { BookList } from './BookList'; @@ -329,7 +329,7 @@ React-admin doesn't support nested resources, but you can use [the `children` pr ```jsx import { Admin, Resource } from 'react-admin'; -import { Route } from 'react-router-dom'; +import { Route } from 'react-router'; export const App = () => ( diff --git a/docs/Routing.md b/docs/Routing.md index 2501a9b37a8..2634b8aef49 100644 --- a/docs/Routing.md +++ b/docs/Routing.md @@ -80,7 +80,7 @@ The `Route` element depends on the routing library you use: ```jsx // for react-router -import { Route } from 'react-router-dom'; +import { Route } from 'react-router'; // for tanstack-router import { tanStackRouterProvider } from 'ra-router-tanstack'; const { Route } = tanStackRouterProvider; @@ -162,7 +162,7 @@ export const MyLayout = ({ children }) => { ## Using A Different Router Library -React-admin supports multiple routing libraries through its router abstraction layer. By default, it uses react-router with a [HashRouter](https://reactrouter.com/en/routers/create-hash-router). You can also use [TanStack Router](./TanStackRouter.md) as an alternative. +React-admin supports multiple routing libraries through its router abstraction layer. By default, it uses react-router (v6/v7) through the [`ra-router-react-router`](./ReactRouterV8.md) adapter with a [HashRouter](https://reactrouter.com/en/routers/create-hash-router). You can also use [TanStack Router](./TanStackRouter.md) or [React Router v8](./ReactRouterV8.md) as an alternative. To use TanStack Router: @@ -188,7 +188,8 @@ By default, react-admin uses react-router with a HashRouter. This means that the But you may want to use another routing strategy, e.g. to allow server-side rendering of individual pages. React-router offers various Router components to implement such routing strategies. If you want to use a different router, simply put your app in a create router function. React-admin will detect that it's already inside a router, and skip its own router. ```tsx -import { RouterProvider, createBrowserRouter } from 'react-router-dom'; +import { RouterProvider } from 'react-router'; +import { createBrowserRouter } from 'react-router-dom'; import { Admin, Resource } from 'react-admin'; import { dataProvider } from './dataProvider'; @@ -215,7 +216,8 @@ However, if you serve your admin from a sub path AND use another Router (like [` ```tsx import { Admin, Resource } from 'react-admin'; -import { RouterProvider, createBrowserRouter } from 'react-router-dom'; +import { RouterProvider } from 'react-router'; +import { createBrowserRouter } from 'react-router-dom'; import { dataProvider } from './dataProvider'; const App = () => { @@ -247,7 +249,8 @@ If you want to use react-admin as a sub path of a larger React application, chec You can include a react-admin app inside another app, using a react-router ``: ```tsx -import { RouterProvider, Routes, Route, createBrowserRouter } from 'react-router-dom'; +import { RouterProvider, Routes, Route } from 'react-router'; +import { createBrowserRouter } from 'react-router-dom'; import { StoreFront } from './StoreFront'; import { StoreAdmin } from './StoreAdmin'; diff --git a/docs/SecurityGuide.md b/docs/SecurityGuide.md index 08f770e0d50..cf305933750 100644 --- a/docs/SecurityGuide.md +++ b/docs/SecurityGuide.md @@ -80,7 +80,7 @@ For custom routes, anonymous users have access by default. To require authentica ```tsx import { Admin, Resource, CustomRoutes, Authenticated } from 'react-admin'; -import { Route } from "react-router-dom"; +import { Route } from "react-router"; import { MyCustomPage } from './MyCustomPage'; const App = () => ( diff --git a/docs/ShowDeleted.md b/docs/ShowDeleted.md index 857e98edb94..0466b3065c1 100644 --- a/docs/ShowDeleted.md +++ b/docs/ShowDeleted.md @@ -14,7 +14,7 @@ It is intended to be used with [`detailComponents`](./DeletedRecordsList.md#deta {% raw %} ```tsx import { Admin, CustomRoutes, SimpleShowLayout, TextField } from 'react-admin'; -import { Route } from 'react-router-dom'; +import { Route } from 'react-router'; import { DeletedRecordsList, ShowDeleted } from '@react-admin/ra-soft-delete'; const ShowDeletedBook = () => ( diff --git a/docs/WithPermissions.md b/docs/WithPermissions.md index 2341072b2c0..ec2f12ad57c 100644 --- a/docs/WithPermissions.md +++ b/docs/WithPermissions.md @@ -10,7 +10,7 @@ The `` component calls `useAuthenticated()` and `useGetPermissi {% raw %} ```jsx import { Admin, CustomRoutes, WithPermissions } from "react-admin"; -import { Route } from "react-router-dom"; +import { Route } from "react-router"; const App = () => ( diff --git a/docs/useDefineAppLocation.md b/docs/useDefineAppLocation.md index eb7908afa09..9a6a8d2d163 100644 --- a/docs/useDefineAppLocation.md +++ b/docs/useDefineAppLocation.md @@ -118,7 +118,7 @@ Let's say that this custom page is added to the app under the `/preferences` URL ```jsx // in src/App.jsx import { Admin, Resource, CustomRoutes, } from 'react-admin'; -import { Route } from 'react-router-dom'; +import { Route } from 'react-router'; import { MyLayout } from './MyLayout'; import { UserPreferences } from './UserPreferences'; diff --git a/docs/usePermissions.md b/docs/usePermissions.md index e50fb6c8af8..c0168f6ec9b 100644 --- a/docs/usePermissions.md +++ b/docs/usePermissions.md @@ -35,7 +35,7 @@ export default MyPage; // in src/customRoutes.js import * as React from "react"; -import { Route } from 'react-router-dom'; +import { Route } from 'react-router'; import MyPage from './MyPage'; export default [ diff --git a/docs_headless/src/content/docs/Architecture.md b/docs_headless/src/content/docs/Architecture.md index 3a6c539e9be..37c2eb28b1b 100644 --- a/docs_headless/src/content/docs/Architecture.md +++ b/docs_headless/src/content/docs/Architecture.md @@ -20,7 +20,7 @@ For example, the following ra-core application: ```jsx import { CoreAdmin, Resource, CustomRoutes } from 'ra-core'; -import { Route } from 'react-router-dom'; +import { Route } from 'react-router'; export const App = () => ( diff --git a/docs_headless/src/content/docs/Authenticated.md b/docs_headless/src/content/docs/Authenticated.md index ec98e47a9b0..e03828c9529 100644 --- a/docs_headless/src/content/docs/Authenticated.md +++ b/docs_headless/src/content/docs/Authenticated.md @@ -10,7 +10,7 @@ Use it as an alternative to the [`useAuthenticated()`](./useAuthenticated.md) ho ```jsx import { CoreAdmin, CustomRoutes, Authenticated } from 'ra-core'; -import { Route } from 'react-router-dom'; +import { Route } from 'react-router'; const App = () => ( diff --git a/docs_headless/src/content/docs/Authentication.md b/docs_headless/src/content/docs/Authentication.md index 3d4bd8249cc..cd68e13e410 100644 --- a/docs_headless/src/content/docs/Authentication.md +++ b/docs_headless/src/content/docs/Authentication.md @@ -94,7 +94,7 @@ When you add custom pages, they are accessible to anonymous users by default. To ```jsx import { CoreAdmin, CustomRoutes, useAuthenticated } from 'ra-core'; -import { Route } from 'react-router-dom'; +import { Route } from 'react-router'; const RestrictedPage = () => { const { isPending } = useAuthenticated(); // redirects to login if not authenticated @@ -126,7 +126,7 @@ Alternatively, use the [`` component](./Authenticated.md) to disp ```jsx import { CoreAdmin, CustomRoutes, Authenticated } from 'ra-core'; -import { Route } from 'react-router-dom'; +import { Route } from 'react-router'; const RestrictedPage = () => ( diff --git a/docs_headless/src/content/docs/CRUD.md b/docs_headless/src/content/docs/CRUD.md index b331aee463d..d29e94342d7 100644 --- a/docs_headless/src/content/docs/CRUD.md +++ b/docs_headless/src/content/docs/CRUD.md @@ -117,7 +117,7 @@ This is the equivalent of the following react-router configuration: ```jsx import { ResourceContextProvider } from 'ra-core'; -import { Routes, Route } from 'react-router-dom'; +import { Routes, Route } from 'react-router'; diff --git a/docs_headless/src/content/docs/CanAccess.md b/docs_headless/src/content/docs/CanAccess.md index 846de9e7151..fa72ae2964c 100644 --- a/docs_headless/src/content/docs/CanAccess.md +++ b/docs_headless/src/content/docs/CanAccess.md @@ -69,7 +69,7 @@ Use the [``](./CustomRoutes.md) component to add custom routes to ```tsx import { CoreAdmin, CustomRoutes } from 'ra-core'; -import { Route } from 'react-router-dom'; +import { Route } from 'react-router'; import { LogsPage } from './LogsPage'; diff --git a/docs_headless/src/content/docs/CoreAdmin.md b/docs_headless/src/content/docs/CoreAdmin.md index 34e0fa2277d..3a96bae6651 100644 --- a/docs_headless/src/content/docs/CoreAdmin.md +++ b/docs_headless/src/content/docs/CoreAdmin.md @@ -35,7 +35,7 @@ In most apps, you need to pass more props to ``. Here is a more compl ```tsx // in src/App.js import { CoreAdmin, Resource, CustomRoutes } from 'ra-core'; -import { Route } from "react-router-dom"; +import { Route } from "react-router"; import { dataProvider, authProvider, i18nProvider } from './providers'; import { Layout } from './layout'; @@ -76,7 +76,7 @@ To make the main app component more concise, a good practice is to move the reso ```tsx // in src/App.js import { CoreAdmin, Resource, CustomRoutes } from 'ra-core'; -import { Route } from "react-router-dom"; +import { Route } from "react-router"; import { dataProvider, authProvider, i18nProvider } from './providers'; import { Layout } from './layout'; @@ -373,7 +373,8 @@ The Auth Provider also lets you configure redirections after login/logout, anony Use this prop to make all routes and links in your Admin relative to a "base" portion of the URL pathname that they all share. This is required when using the [`BrowserRouter`](https://reactrouter.com/en/main/router-components/browser-router) to serve the application under a sub-path of your domain (for example https://marmelab.com/ra-enterprise-demo), or when embedding ra-core inside a single-page app with its own routing. ```tsx -import { BrowserRouter, Routes, Route } from 'react-router-dom'; +import { Routes, Route } from 'react-router'; +import { BrowserRouter } from 'react-router-dom'; import { StoreFront } from './StoreFront'; import { StoreAdmin } from './StoreAdmin'; @@ -1079,7 +1080,7 @@ In addition to [` elements`](./Resource.md) for CRUD pages, you can us ```tsx // in src/App.js import * as React from "react"; -import { Route } from 'react-router-dom'; +import { Route } from 'react-router'; import { CoreAdmin, Resource, CustomRoutes } from 'ra-core'; import posts from './posts'; import comments from './comments'; @@ -1111,7 +1112,8 @@ By default, ra-core uses react-router with a [HashRouter](https://reactrouter.co But you may want to use another routing strategy, e.g. to allow server-side rendering of individual pages. React-router offers various Router components to implement such routing strategies. If you want to use a different router, simply put your app in a create router function. Ra-core will detect that it's already inside a router, and skip its own router. ```tsx -import { RouterProvider, createBrowserRouter } from 'react-router-dom'; +import { RouterProvider } from 'react-router'; +import { createBrowserRouter } from 'react-router-dom'; import { CoreAdmin, Resource } from 'ra-core'; import { dataProvider } from './dataProvider'; @@ -1138,7 +1140,8 @@ However, if you serve your admin from a sub path AND use another Router (like [` ```tsx import { CoreAdmin, Resource } from 'ra-core'; -import { RouterProvider, createBrowserRouter } from 'react-router-dom'; +import { RouterProvider } from 'react-router'; +import { createBrowserRouter } from 'react-router-dom'; import { dataProvider } from './dataProvider'; const App = () => { @@ -1170,7 +1173,8 @@ If you want to use ra-core as a sub path of a larger React application, check th You can include a ra-core app inside another app, using a react-router ``: ```tsx -import { RouterProvider, Routes, Route, createBrowserRouter } from 'react-router-dom'; +import { RouterProvider, Routes, Route } from 'react-router'; +import { createBrowserRouter } from 'react-router-dom'; import { StoreFront } from './StoreFront'; import { StoreAdmin } from './StoreAdmin'; diff --git a/docs_headless/src/content/docs/CustomRoutes.md b/docs_headless/src/content/docs/CustomRoutes.md index 9416502a655..6753a753bb2 100644 --- a/docs_headless/src/content/docs/CustomRoutes.md +++ b/docs_headless/src/content/docs/CustomRoutes.md @@ -41,7 +41,7 @@ The `Route` element depends on the routing library you use (e.g. `react-router` ```jsx // for react-router -import { Route } from 'react-router-dom'; +import { Route } from 'react-router'; // for tanstack-router import { tanStackRouterProvider } from 'ra-router-tanstack'; const { Route } = tanStackRouterProvider; @@ -56,7 +56,7 @@ Now, when a user browses to `/settings` or `/profile`, the components you define ```jsx // in src/App.js import { CoreAdmin, Resource, CustomRoutes } from 'ra-core'; -import { Route } from "react-router-dom"; +import { Route } from "react-router"; import { dataProvider } from './dataProvider'; import { Settings } from './Settings'; @@ -87,7 +87,7 @@ Here is an example of application configuration mixing custom routes with and wi ```jsx // in src/App.js import { CoreAdmin, CustomRoutes } from 'ra-core'; -import { Route } from "react-router-dom"; +import { Route } from "react-router"; import { dataProvider } from './dataProvider'; import { Register } from './Register'; @@ -116,7 +116,7 @@ By default, custom routes can be accessed even by anomymous users. If you want t ```jsx // in src/App.js import { CoreAdmin, CustomRoutes, Authenticated } from 'ra-core'; -import { Route } from "react-router-dom"; +import { Route } from "react-router"; import { dataProvider } from './dataProvider'; import { Settings } from './Settings'; @@ -155,7 +155,7 @@ To do so, add the `` elements as [children of the `` element](. ```jsx import { CoreAdmin, Resource } from 'ra-core'; -import { Route } from "react-router-dom"; +import { Route } from "react-router"; import { dataProvider } from './dataProvider'; import posts from './posts'; @@ -184,7 +184,7 @@ This is usually useful for nested resources, such as books on authors: ```jsx // in src/App.js import { CoreAdmin, Resource } from 'ra-core'; -import { Route } from "react-router-dom"; +import { Route } from "react-router"; import { AuthorList } from './AuthorList'; import { AuthorEdit } from './AuthorEdit'; diff --git a/docs_headless/src/content/docs/DeletedRecordRepresentation.md b/docs_headless/src/content/docs/DeletedRecordRepresentation.md index 1f17bf66750..3f3f0526d99 100644 --- a/docs_headless/src/content/docs/DeletedRecordRepresentation.md +++ b/docs_headless/src/content/docs/DeletedRecordRepresentation.md @@ -18,7 +18,7 @@ yarn add @react-admin/ra-core-ee ```tsx import { CoreAdmin, CustomRoutes, WithRecord } from 'react-admin'; -import { Route } from 'react-router-dom'; +import { Route } from 'react-router'; import { DeletedRecordsListBase, ShowDeletedBase, type DeletedRecordType } from '@react-admin/ra-core-ee'; export const App = () => ( diff --git a/docs_headless/src/content/docs/DeletedRecordsListBase.md b/docs_headless/src/content/docs/DeletedRecordsListBase.md index 0696a1e51a6..376aaeb29df 100644 --- a/docs_headless/src/content/docs/DeletedRecordsListBase.md +++ b/docs_headless/src/content/docs/DeletedRecordsListBase.md @@ -22,7 +22,7 @@ However, you need to define the route to reach this component manually using [`< ```tsx // in src/App.js import { CoreAdmin, CustomRoutes } from 'ra-core'; -import { Route } from 'react-router-dom'; +import { Route } from 'react-router'; import { DeletedRecordsListBase, DeletedRecordRepresentation } from '@react-admin/ra-core-ee'; export const App = () => ( @@ -302,7 +302,7 @@ In the example below, the deleted records lists store their list parameters sepa ```tsx import { CoreAdmin, CustomRoutes } from 'ra-core'; -import { Route } from 'react-router-dom'; +import { Route } from 'react-router'; import { DeletedRecordsListBase } from '@react-admin/ra-core-ee'; const Admin = () => { diff --git a/docs_headless/src/content/docs/Permissions.md b/docs_headless/src/content/docs/Permissions.md index ccfe3036588..ccf87bfad3a 100644 --- a/docs_headless/src/content/docs/Permissions.md +++ b/docs_headless/src/content/docs/Permissions.md @@ -258,7 +258,7 @@ Use the [``](./CustomRoutes.md) component to add custom routes to ```tsx import { CoreAdmin, CustomRoutes, Authenticated, CanAccess } from 'ra-core'; import { AccessDenied } from './AccessDenied'; -import { Route } from 'react-router-dom'; +import { Route } from 'react-router'; import { LogsPage } from './LogsPage'; const App = () => ( diff --git a/docs_headless/src/content/docs/Resource.md b/docs_headless/src/content/docs/Resource.md index 889f38acf5b..88f7b65bff2 100644 --- a/docs_headless/src/content/docs/Resource.md +++ b/docs_headless/src/content/docs/Resource.md @@ -120,7 +120,7 @@ For instance, the following code creates an `authors` resource, and adds an `/au ```jsx // in src/App.jsx import { CoreAdmin, Resource } from 'ra-core'; -import { Route } from 'react-router-dom'; +import { Route } from 'react-router'; import { AuthorList } from './AuthorList'; import { BookList } from './BookList'; @@ -344,7 +344,7 @@ Ra-core doesn't support nested resources, but you can use [the `children` prop]( ```jsx import { CoreAdmin, Resource } from 'ra-core'; -import { Route } from 'react-router-dom'; +import { Route } from 'react-router'; export const App = () => ( diff --git a/docs_headless/src/content/docs/Routing.md b/docs_headless/src/content/docs/Routing.md index b5665c5247f..e01e143893a 100644 --- a/docs_headless/src/content/docs/Routing.md +++ b/docs_headless/src/content/docs/Routing.md @@ -79,7 +79,7 @@ The `Route` element depends on the routing library you use (e.g. `react-router` ```jsx // for react-router -import { Route } from 'react-router-dom'; +import { Route } from 'react-router'; // for tanstack-router import { tanStackRouterProvider } from 'ra-router-tanstack'; const { Route } = tanStackRouterProvider; @@ -187,7 +187,8 @@ By default, ra-core uses react-router with a HashRouter. This means that the has But you may want to use another routing strategy, e.g. to allow server-side rendering of individual pages. React-router offers various Router components to implement such routing strategies. If you want to use a different router, simply put your app in a create router function. Ra-core will detect that it's already inside a router, and skip its own router. ```tsx -import { RouterProvider, createBrowserRouter } from 'react-router-dom'; +import { RouterProvider } from 'react-router'; +import { createBrowserRouter } from 'react-router-dom'; import { CoreAdmin, Resource } from 'ra-core'; import { dataProvider } from './dataProvider'; @@ -214,7 +215,8 @@ However, if you serve your admin from a sub path AND use another Router (like [` ```tsx import { CoreAdmin, Resource } from 'ra-core'; -import { RouterProvider, createBrowserRouter } from 'react-router-dom'; +import { RouterProvider } from 'react-router'; +import { createBrowserRouter } from 'react-router-dom'; import { dataProvider } from './dataProvider'; const App = () => { @@ -246,7 +248,8 @@ If you want to use ra-core as a sub path of a larger React application, check th You can include an ra-core app inside another app, using a react-router ``: ```tsx -import { RouterProvider, Routes, Route, createBrowserRouter } from 'react-router-dom'; +import { RouterProvider, Routes, Route } from 'react-router'; +import { createBrowserRouter } from 'react-router-dom'; import { StoreFront } from './StoreFront'; import { StoreAdmin } from './StoreAdmin'; diff --git a/docs_headless/src/content/docs/SecurityGuide.md b/docs_headless/src/content/docs/SecurityGuide.md index a94e2a6faa2..5421fb448e0 100644 --- a/docs_headless/src/content/docs/SecurityGuide.md +++ b/docs_headless/src/content/docs/SecurityGuide.md @@ -79,7 +79,7 @@ For custom routes, anonymous users have access by default. To require authentica ```tsx import { CoreAdmin, Resource, CustomRoutes, Authenticated } from 'ra-core'; -import { Route } from "react-router-dom"; +import { Route } from "react-router"; import { MyCustomPage } from './MyCustomPage'; const App = () => ( diff --git a/docs_headless/src/content/docs/ShowDeletedBase.md b/docs_headless/src/content/docs/ShowDeletedBase.md index 968b2767736..28ee5d01a34 100644 --- a/docs_headless/src/content/docs/ShowDeletedBase.md +++ b/docs_headless/src/content/docs/ShowDeletedBase.md @@ -20,7 +20,7 @@ yarn add @react-admin/ra-core-ee ```tsx import { CoreAdmin, CustomRoutes, WithRecord } from 'ra-core'; -import { Route } from 'react-router-dom'; +import { Route } from 'react-router'; import { DeletedRecordsListBase, DeletedRecordRepresentation, ShowDeletedBase, type DeletedRecordType } from '@react-admin/ra-core-ee'; export const App = () => ( diff --git a/docs_headless/src/content/docs/usePermissions.md b/docs_headless/src/content/docs/usePermissions.md index d0521a37b68..448c7de05b0 100644 --- a/docs_headless/src/content/docs/usePermissions.md +++ b/docs_headless/src/content/docs/usePermissions.md @@ -30,7 +30,7 @@ export default MyPage; // in src/customRoutes.js import * as React from "react"; -import { Route } from 'react-router-dom'; +import { Route } from 'react-router'; import MyPage from './MyPage'; export default [ diff --git a/jest.config.js b/jest.config.js index 7e69b06b311..886619755a2 100644 --- a/jest.config.js +++ b/jest.config.js @@ -23,6 +23,12 @@ module.exports = { '/esm/', '/examples/simple/', '/packages/create-react-admin/templates', + // ra-router-react-router-next is ESM-only and React 19; it runs as a + // separate jest invocation (its own jest.config.cjs) from the test + // scripts, under `NODE_OPTIONS=--experimental-vm-modules`. Enabling that + // flag process-wide here would force the rest of the suite into ESM mode + // and break the CommonJS deps it transforms (e.g. react-hotkeys-hook). + '/packages/ra-router-react-router-next/', ], transformIgnorePatterns: [ '[/\\\\]node_modules[/\\\\](?!(@hookform|react-hotkeys-hook|@faker-js/faker)/).+\\.(js|jsx|mjs|ts|tsx)$', diff --git a/package.json b/package.json index 37f24c146fe..5c4bb967872 100644 --- a/package.json +++ b/package.json @@ -5,8 +5,8 @@ "build": "lerna run build", "typecheck": "CI=true lerna run build", "watch": "lerna run --parallel watch", - "test-unit": "cross-env LANG=en_US.UTF-8 NODE_ENV=test cross-env BABEL_ENV=cjs NODE_ICU_DATA=./node_modules/full-icu jest", - "test-unit-ci": "cross-env LANG=en_US.UTF-8 NODE_ENV=test cross-env BABEL_ENV=cjs NODE_ICU_DATA=./node_modules/full-icu jest --runInBand", + "test-unit": "cross-env LANG=en_US.UTF-8 NODE_ENV=test cross-env BABEL_ENV=cjs NODE_ICU_DATA=./node_modules/full-icu jest && cross-env LANG=en_US.UTF-8 NODE_ENV=test cross-env BABEL_ENV=cjs NODE_ICU_DATA=./node_modules/full-icu NODE_OPTIONS=--experimental-vm-modules jest --config packages/ra-router-react-router-next/jest.config.cjs", + "test-unit-ci": "cross-env LANG=en_US.UTF-8 NODE_ENV=test cross-env BABEL_ENV=cjs NODE_ICU_DATA=./node_modules/full-icu jest --runInBand && cross-env LANG=en_US.UTF-8 NODE_ENV=test cross-env BABEL_ENV=cjs NODE_ICU_DATA=./node_modules/full-icu NODE_OPTIONS=--experimental-vm-modules jest --config packages/ra-router-react-router-next/jest.config.cjs --runInBand", "test-e2e": "yarn run -s build && cross-env NODE_ENV=test && cd cypress && yarn test", "test-e2e-local": "cd cypress && yarn start", "test": "yarn test-unit && yarn test-e2e", diff --git a/packages/ra-core/package.json b/packages/ra-core/package.json index 9d5f2d31be9..748ee3da6e6 100644 --- a/packages/ra-core/package.json +++ b/packages/ra-core/package.json @@ -65,9 +65,7 @@ "@tanstack/react-query": "^5.83.0", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0", - "react-hook-form": "^7.72.0", - "react-router": "^6.28.1 || ^7.1.1", - "react-router-dom": "^6.28.1 || ^7.1.1" + "react-hook-form": "^7.72.0" }, "dependencies": { "date-fns": "^3.6.0", @@ -76,6 +74,7 @@ "jsonexport": "^3.2.0", "lodash": "^4.17.21", "query-string": "^7.1.3", + "ra-router-react-router": "^5.14.7", "react-error-boundary": "^4.0.13", "react-is": "^18.2.0 || ^19.0.0" }, diff --git a/packages/ra-core/src/auth/Authenticated.spec.tsx b/packages/ra-core/src/auth/Authenticated.spec.tsx index 17ee52ad18f..d5820550855 100644 --- a/packages/ra-core/src/auth/Authenticated.spec.tsx +++ b/packages/ra-core/src/auth/Authenticated.spec.tsx @@ -1,7 +1,7 @@ import * as React from 'react'; import expect from 'expect'; import { render, screen, waitFor } from '@testing-library/react'; -import { Routes, Route } from 'react-router-dom'; +import { Routes, Route } from 'react-router'; import { memoryStore } from '../store'; import { CoreAdminContext } from '../core'; diff --git a/packages/ra-core/src/auth/useAuthenticated.spec.tsx b/packages/ra-core/src/auth/useAuthenticated.spec.tsx index 58dbc4dd0f8..063f8a5eab4 100644 --- a/packages/ra-core/src/auth/useAuthenticated.spec.tsx +++ b/packages/ra-core/src/auth/useAuthenticated.spec.tsx @@ -1,7 +1,7 @@ import * as React from 'react'; import expect from 'expect'; import { render, screen, waitFor } from '@testing-library/react'; -import { Routes, Route, useLocation } from 'react-router-dom'; +import { Routes, Route, useLocation } from 'react-router'; import { memoryStore } from '../store'; import { useNotificationContext } from '../notification'; diff --git a/packages/ra-core/src/auth/useHandleAuthCallback.spec.tsx b/packages/ra-core/src/auth/useHandleAuthCallback.spec.tsx index 7452fa26df0..af2e3587356 100644 --- a/packages/ra-core/src/auth/useHandleAuthCallback.spec.tsx +++ b/packages/ra-core/src/auth/useHandleAuthCallback.spec.tsx @@ -1,7 +1,7 @@ import * as React from 'react'; import expect from 'expect'; import { render, screen, waitFor } from '@testing-library/react'; -import { Route, Routes } from 'react-router-dom'; +import { Route, Routes } from 'react-router'; import { QueryClientProvider, QueryClient } from '@tanstack/react-query'; import { useHandleAuthCallback } from './useHandleAuthCallback'; diff --git a/packages/ra-core/src/auth/useLogin.spec.tsx b/packages/ra-core/src/auth/useLogin.spec.tsx index c76296a5a99..7049fb88e7d 100644 --- a/packages/ra-core/src/auth/useLogin.spec.tsx +++ b/packages/ra-core/src/auth/useLogin.spec.tsx @@ -1,6 +1,6 @@ import * as React from 'react'; import { render, screen, fireEvent, waitFor } from '@testing-library/react'; -import { Routes, Route } from 'react-router-dom'; +import { Routes, Route } from 'react-router'; import expect from 'expect'; import { CoreAdminContext } from '../core/CoreAdminContext'; diff --git a/packages/ra-core/src/auth/useLogout.spec.tsx b/packages/ra-core/src/auth/useLogout.spec.tsx index b8f89fe4c2c..512b0112a88 100644 --- a/packages/ra-core/src/auth/useLogout.spec.tsx +++ b/packages/ra-core/src/auth/useLogout.spec.tsx @@ -1,6 +1,6 @@ import * as React from 'react'; import { render, fireEvent, screen } from '@testing-library/react'; -import { Routes, Route } from 'react-router-dom'; +import { Routes, Route } from 'react-router'; import { QueryClient } from '@tanstack/react-query'; import expect from 'expect'; diff --git a/packages/ra-core/src/auth/useLogoutIfAccessDenied.spec.tsx b/packages/ra-core/src/auth/useLogoutIfAccessDenied.spec.tsx index d0fb5f7af7b..c44cd5f1917 100644 --- a/packages/ra-core/src/auth/useLogoutIfAccessDenied.spec.tsx +++ b/packages/ra-core/src/auth/useLogoutIfAccessDenied.spec.tsx @@ -2,7 +2,7 @@ import * as React from 'react'; import { useEffect, useState } from 'react'; import expect from 'expect'; import { render, screen, waitFor } from '@testing-library/react'; -import { Routes, Route } from 'react-router-dom'; +import { Routes, Route } from 'react-router'; import useLogoutIfAccessDenied from './useLogoutIfAccessDenied'; import { AuthContext } from './AuthContext'; diff --git a/packages/ra-core/src/controller/create/useCreateController.spec.tsx b/packages/ra-core/src/controller/create/useCreateController.spec.tsx index 4dfe5e92736..e938294a22d 100644 --- a/packages/ra-core/src/controller/create/useCreateController.spec.tsx +++ b/packages/ra-core/src/controller/create/useCreateController.spec.tsx @@ -7,7 +7,7 @@ import { } from '@testing-library/react'; import expect from 'expect'; import React from 'react'; -import { Route, Routes } from 'react-router-dom'; +import { Route, Routes } from 'react-router'; import { AuthProvider, diff --git a/packages/ra-core/src/controller/usePrevNextController.ts b/packages/ra-core/src/controller/usePrevNextController.ts index 29149df0d47..8d809a5cb63 100644 --- a/packages/ra-core/src/controller/usePrevNextController.ts +++ b/packages/ra-core/src/controller/usePrevNextController.ts @@ -37,11 +37,10 @@ import { useCreatePath } from '../routing'; * * @example Custom PrevNextButton * - * import { UsePrevNextControllerProps, useTranslate } from 'ra-core'; + * import { UsePrevNextControllerProps, useTranslate, LinkBase as Link } from 'ra-core'; * import NavigateBefore from '@mui/icons-material/NavigateBefore'; * import NavigateNext from '@mui/icons-material/NavigateNext'; * import ErrorIcon from '@mui/icons-material/Error'; - * import { Link } from 'react-router-dom'; * import { CircularProgress, IconButton } from '@mui/material'; * * const MyPrevNextButtons = props => { @@ -78,7 +77,7 @@ import { useCreatePath } from '../routing'; *
  • * @@ -93,7 +92,7 @@ import { useCreatePath } from '../routing'; *
  • * diff --git a/packages/ra-core/src/core/CoreAdminContext.tsx b/packages/ra-core/src/core/CoreAdminContext.tsx index ce2a13a80f5..95e174bbdcb 100644 --- a/packages/ra-core/src/core/CoreAdminContext.tsx +++ b/packages/ra-core/src/core/CoreAdminContext.tsx @@ -2,12 +2,8 @@ import * as React from 'react'; import { useMemo } from 'react'; import { QueryClientProvider, QueryClient } from '@tanstack/react-query'; -import { - AdminRouter, - RouterProviderContext, - RouterProvider, - reactRouterProvider, -} from '../routing'; +import { reactRouterProvider } from 'ra-router-react-router'; +import { AdminRouter, RouterProviderContext, RouterProvider } from '../routing'; import { AuthContext, convertLegacyAuthProvider } from '../auth'; import { DataProviderContext, @@ -174,7 +170,7 @@ export interface CoreAdminContextProps { * The router provider for custom routing implementations * * Use this to integrate react-admin with alternative routers like TanStack Router. - * Defaults to react-router-dom. + * Defaults to react-router. * * @see https://marmelab.com/react-admin/Admin.html#routerprovider * @example diff --git a/packages/ra-core/src/core/CoreAdminRoutes.spec.tsx b/packages/ra-core/src/core/CoreAdminRoutes.spec.tsx index 587b88aa99b..82f10e4026f 100644 --- a/packages/ra-core/src/core/CoreAdminRoutes.spec.tsx +++ b/packages/ra-core/src/core/CoreAdminRoutes.spec.tsx @@ -1,7 +1,7 @@ import * as React from 'react'; import { render, screen, waitFor } from '@testing-library/react'; import expect from 'expect'; -import { Route } from 'react-router-dom'; +import { Route } from 'react-router'; import { CoreAdminContext } from './CoreAdminContext'; import { RouterNavigateFunction, TestMemoryRouter } from '../routing'; diff --git a/packages/ra-core/src/core/useConfigureAdminRouterFromChildren.spec.tsx b/packages/ra-core/src/core/useConfigureAdminRouterFromChildren.spec.tsx index 457aa405a75..fb8b8b45ef7 100644 --- a/packages/ra-core/src/core/useConfigureAdminRouterFromChildren.spec.tsx +++ b/packages/ra-core/src/core/useConfigureAdminRouterFromChildren.spec.tsx @@ -1,7 +1,7 @@ import * as React from 'react'; import { render, screen, waitFor } from '@testing-library/react'; import expect from 'expect'; -import { Route } from 'react-router-dom'; +import { Route } from 'react-router'; import { useResourceDefinitions } from './useResourceDefinitions'; import { CoreAdminContext } from './CoreAdminContext'; diff --git a/packages/ra-core/src/dataProvider/useGetRecordId.spec.tsx b/packages/ra-core/src/dataProvider/useGetRecordId.spec.tsx index 279ef3b7aae..63578e87fca 100644 --- a/packages/ra-core/src/dataProvider/useGetRecordId.spec.tsx +++ b/packages/ra-core/src/dataProvider/useGetRecordId.spec.tsx @@ -1,7 +1,7 @@ import * as React from 'react'; import { useGetRecordId } from './useGetRecordId'; import { render, screen } from '@testing-library/react'; -import { Route, Routes } from 'react-router-dom'; +import { Route, Routes } from 'react-router'; import { RecordContextProvider } from '../controller'; import { TestMemoryRouter } from '../routing'; diff --git a/packages/ra-core/src/form/Form.stories.tsx b/packages/ra-core/src/form/Form.stories.tsx index dcc1ba93d51..b1312be19aa 100644 --- a/packages/ra-core/src/form/Form.stories.tsx +++ b/packages/ra-core/src/form/Form.stories.tsx @@ -11,14 +11,8 @@ import * as yup from 'yup'; import polyglotI18nProvider from 'ra-i18n-polyglot'; import englishMessages from 'ra-language-english'; import fakeRestDataProvider from 'ra-data-fakerest'; -import { - Route, - Routes, - useNavigate, - Link, - HashRouter, - useLocation, -} from 'react-router-dom'; +import { Route, Routes, useNavigate, useLocation } from 'react-router'; +import { HashRouter } from 'react-router-dom'; import { CoreAdminContext } from '../core'; import { @@ -32,7 +26,7 @@ import { useInput } from './useInput'; import { required, ValidationError } from './validation'; import { mergeTranslations } from '../i18n'; import { I18nProvider, RaRecord } from '../types'; -import { TestMemoryRouter } from '../routing'; +import { TestMemoryRouter, LinkBase as Link } from '../routing'; import { useNotificationContext } from '../notification'; export default { diff --git a/packages/ra-core/src/form/useWarnWhenUnsavedChanges.spec.tsx b/packages/ra-core/src/form/useWarnWhenUnsavedChanges.spec.tsx index 68192aba421..362a6376d4f 100644 --- a/packages/ra-core/src/form/useWarnWhenUnsavedChanges.spec.tsx +++ b/packages/ra-core/src/form/useWarnWhenUnsavedChanges.spec.tsx @@ -2,7 +2,7 @@ import * as React from 'react'; import expect from 'expect'; import { fireEvent, render, screen, waitFor } from '@testing-library/react'; import { useForm, useFormContext, FormProvider } from 'react-hook-form'; -import { Route, Routes } from 'react-router-dom'; +import { Route, Routes } from 'react-router'; import { TestMemoryRouter, useNavigate, useParams } from '../routing'; import { useWarnWhenUnsavedChanges } from './useWarnWhenUnsavedChanges'; diff --git a/packages/ra-core/src/index.ts b/packages/ra-core/src/index.ts index bd415c3c80f..4b979d4fc2b 100644 --- a/packages/ra-core/src/index.ts +++ b/packages/ra-core/src/index.ts @@ -14,3 +14,5 @@ export * from './store'; export * from './types'; export * from './util'; export * as testUI from './test-ui'; + +export * from 'ra-router-react-router'; diff --git a/packages/ra-core/src/routing/README.md b/packages/ra-core/src/routing/README.md index e653ef543aa..7fbd841c28e 100644 --- a/packages/ra-core/src/routing/README.md +++ b/packages/ra-core/src/routing/README.md @@ -32,9 +32,11 @@ RouterProviderContext │ ├── Components: Link, Navigate, Route, Routes, Outlet │ └── Utilities: matchPath, RouterWrapper │ - ├── reactRouterProvider (default implementation) + ├── reactRouterProvider (default implementation, in the ra-router-react-router package) │ - └── tanStackRouterProvider (alternative implementation) + ├── reactRouterNextProvider (opt-in implementation for react-router v8, in the ra-router-react-router-next package) + │ + └── tanStackRouterProvider (alternative implementation, in the ra-router-tanstack package) ``` ### Context Flow @@ -119,7 +121,7 @@ export const myRouterProvider: RouterProvider = { 2. **Route/Routes translation**: If your router uses configuration-based routing (like TanStack Router), implement a translation layer that accepts JSX-based `` elements. -3. **Duck-typing for Route detection**: The Routes component should use duck-typing to detect Route elements, not strict type checking. This allows users to import Route from react-router-dom. +3. **Duck-typing for Route detection**: The Routes component should use duck-typing to detect Route elements, not strict type checking. This allows users to import Route from react-router. ```typescript // Good: duck-typing @@ -139,7 +141,7 @@ The abstraction maintains full backward compatibility with react-admin's existin 1. **Default provider**: `reactRouterProvider` is the default, so existing apps work without changes 2. **Import from react-admin**: Hooks like `useNavigate`, `useLocation`, `useParams` can be imported from `react-admin` -3. **react-router imports still work**: Users can still import directly from react-router-dom if they prefer +3. **react-router imports still work**: Users can still import directly from react-router if they prefer ## Key Files Reference @@ -147,6 +149,7 @@ The abstraction maintains full backward compatibility with react-admin's existin |------|---------| | `RouterProvider.ts` | The interface contract all adapters must implement | | `RouterProviderContext.tsx` | Context and `useRouterProvider` hook | -| `adapters/reactRouterProvider.tsx` | Default implementation using react-router | -| `adapters/tanStackRouterProvider.tsx` | Alternative implementation using TanStack Router | +| `ra-router-react-router` (package) | Default implementation using react-router v6/v7 (`reactRouterProvider`) | +| `ra-router-react-router-next` (package) | Opt-in implementation for react-router v8 (`reactRouterNextProvider`) | +| `ra-router-tanstack` (package) | Alternative implementation using TanStack Router | | `AdminRouter.tsx` | High-level component that sets up routing for Admin | diff --git a/packages/ra-core/src/routing/RouterProviderContext.tsx b/packages/ra-core/src/routing/RouterProviderContext.tsx index cf0ecaadd50..6fa9baddbff 100644 --- a/packages/ra-core/src/routing/RouterProviderContext.tsx +++ b/packages/ra-core/src/routing/RouterProviderContext.tsx @@ -1,6 +1,6 @@ import { createContext, useContext } from 'react'; +import { reactRouterProvider } from 'ra-router-react-router'; import type { RouterProvider } from './RouterProvider'; -import { reactRouterProvider } from './adapters/reactRouterProvider'; /** * Context for providing the router provider throughout the application. diff --git a/packages/ra-core/src/routing/TestMemoryRouter.tsx b/packages/ra-core/src/routing/TestMemoryRouter.tsx index 331cffe79e4..987918b77b7 100644 --- a/packages/ra-core/src/routing/TestMemoryRouter.tsx +++ b/packages/ra-core/src/routing/TestMemoryRouter.tsx @@ -6,8 +6,11 @@ import { Location, useNavigate, NavigateFunction, -} from 'react-router-dom'; -import type { InitialEntry } from '@remix-run/router'; +} from 'react-router'; + +// Redefining `InitialEntry` type locally to keep `TestMemoryRouter` compatible across +// react-router v6/v7/v8 because v8 no longer depends on `@remix-run/router` +type InitialEntry = string | Partial; const UseLocation = ({ locationCallback, diff --git a/packages/ra-core/src/routing/adapters/reactRouterProvider.tsx b/packages/ra-core/src/routing/adapters/reactRouterProvider.tsx deleted file mode 100644 index 29f3da7c482..00000000000 --- a/packages/ra-core/src/routing/adapters/reactRouterProvider.tsx +++ /dev/null @@ -1,133 +0,0 @@ -import * as React from 'react'; -import { useContext, useEffect, useRef, ReactNode } from 'react'; -import { - useNavigate as useReactRouterNavigate, - useLocation, - useParams, - useBlocker, - useMatch, - useInRouterContext, - Link, - Navigate, - Route, - Routes, - Outlet, - matchPath, - createHashRouter, - RouterProvider as ReactRouterProvider, - UNSAFE_DataRouterContext, - UNSAFE_DataRouterStateContext, - type FutureConfig, -} from 'react-router-dom'; -import type { - RouterProvider, - RouterWrapperProps, - RouterNavigateFunction, -} from '../RouterProvider'; - -const routerProviderFuture: Partial< - Pick -> = { v7_startTransition: false, v7_relativeSplatPath: false }; - -/** - * Hook to check if navigation blocking is supported. - * In react-router, blocking requires a data router. - */ -const useCanBlock = (): boolean => { - const dataRouterContext = useContext(UNSAFE_DataRouterContext); - const dataRouterStateContext = useContext(UNSAFE_DataRouterStateContext); - return !!(dataRouterContext && dataRouterStateContext); -}; - -/** - * Wrapper around react-router's useNavigate that returns a stable function reference. - * - * react-router's useNavigate forces rerenders on every navigation, even if we don't use the result. - * @see https://github.com/remix-run/react-router/issues/7634 - * - * This wrapper uses a ref to return a stable function reference, avoiding unnecessary rerenders - * in components that use navigate but don't need to rerender on navigation. - */ -const useNavigate = (): RouterNavigateFunction => { - const navigate = useReactRouterNavigate(); - const navigateRef = useRef( - navigate as RouterNavigateFunction - ); - - useEffect(() => { - navigateRef.current = navigate as RouterNavigateFunction; - }, [navigate]); - - // Return a stable function that always calls the latest navigate - return React.useCallback((...args: Parameters) => { - return navigateRef.current(...args); - }, []) as RouterNavigateFunction; -}; - -/** - * Internal router component that creates a HashRouter. - * Only used when not already inside a router context. - */ -const InternalRouter = ({ - children, - basename, -}: { - children: ReactNode; - basename?: string; -}) => { - const router = createHashRouter([{ path: '*', element: <>{children} }], { - basename, - future: { - v7_fetcherPersist: false, - v7_normalizeFormMethod: false, - v7_partialHydration: false, - v7_relativeSplatPath: false, - v7_skipActionErrorRevalidation: false, - }, - }); - return ( - - ); -}; - -/** - * RouterWrapper component for react-router. - * Creates a HashRouter if not already inside a router context. - */ -const RouterWrapper = ({ basename, children }: RouterWrapperProps) => { - const isInRouter = useInRouterContext(); - - if (isInRouter) { - return <>{children}; - } - - return {children}; -}; - -/** - * Default router provider using react-router-dom. - * This provider is used by default when no custom routerProvider is provided to . - */ -export const reactRouterProvider: RouterProvider = { - // Hooks - useNavigate, - useLocation, - useParams: useParams as RouterProvider['useParams'], - useBlocker, - useMatch, - useInRouterContext, - useCanBlock, - - // Components - Link, - Navigate, - Route, - Routes, - Outlet, - - // Router creation - RouterWrapper, - - // Utilities - matchPath, -}; diff --git a/packages/ra-core/src/routing/index.ts b/packages/ra-core/src/routing/index.ts index 12e527ee7d4..5fea5354ed4 100644 --- a/packages/ra-core/src/routing/index.ts +++ b/packages/ra-core/src/routing/index.ts @@ -14,7 +14,6 @@ export * from './TestMemoryRouter'; export * from './useSplatPathBase'; export * from './RouterProvider'; export * from './RouterProviderContext'; -export * from './adapters/reactRouterProvider'; export * from './useLocation'; export * from './useNavigate'; export * from './useParams'; diff --git a/packages/ra-core/src/routing/useCreatePath.stories.tsx b/packages/ra-core/src/routing/useCreatePath.stories.tsx index b68f6a32cc0..72b3beedfaa 100644 --- a/packages/ra-core/src/routing/useCreatePath.stories.tsx +++ b/packages/ra-core/src/routing/useCreatePath.stories.tsx @@ -1,5 +1,5 @@ import * as React from 'react'; -import { Routes, Route } from 'react-router-dom'; +import { Routes, Route } from 'react-router'; import { BasenameContextProvider } from './BasenameContextProvider'; import { useBasename } from './useBasename'; diff --git a/packages/ra-core/src/routing/useRedirect.spec.tsx b/packages/ra-core/src/routing/useRedirect.spec.tsx index 247cddcd249..839ea7754f5 100644 --- a/packages/ra-core/src/routing/useRedirect.spec.tsx +++ b/packages/ra-core/src/routing/useRedirect.spec.tsx @@ -2,7 +2,7 @@ import * as React from 'react'; import { useEffect } from 'react'; import expect from 'expect'; import { fireEvent, render, screen, waitFor } from '@testing-library/react'; -import { Routes, Route } from 'react-router-dom'; +import { Routes, Route } from 'react-router'; import { CoreAdminContext } from '../core'; import { useLocation } from './useLocation'; diff --git a/packages/ra-core/src/routing/useRedirect.stories.tsx b/packages/ra-core/src/routing/useRedirect.stories.tsx index e6bf9d2dda8..4442196a90f 100644 --- a/packages/ra-core/src/routing/useRedirect.stories.tsx +++ b/packages/ra-core/src/routing/useRedirect.stories.tsx @@ -1,11 +1,6 @@ import * as React from 'react'; -import { - Link, - Routes, - Route, - useLocation, - useNavigate, -} from 'react-router-dom'; +import { Routes, Route, useLocation, useNavigate } from 'react-router'; +import { LinkBase as Link } from './LinkBase'; import { FakeBrowserDecorator } from '../storybook//FakeBrowser'; import { useRedirect as useRedirectRA } from './useRedirect'; diff --git a/packages/ra-core/src/routing/useScrollToTop.tsx b/packages/ra-core/src/routing/useScrollToTop.tsx index b6a4217b0f0..8cdd6fec694 100644 --- a/packages/ra-core/src/routing/useScrollToTop.tsx +++ b/packages/ra-core/src/routing/useScrollToTop.tsx @@ -7,7 +7,7 @@ import { useLocation } from './useLocation'; * @see CoreAdminRouter where it's enabled by default * * @example // usage in buttons - * import { Link } from 'react-router-dom'; + * import { LinkBase as Link } from 'ra-core'; * import { Button } from '@mui/material'; * * const FooButton = () => ( diff --git a/packages/ra-input-rich-text/src/RichTextInput.stories.tsx b/packages/ra-input-rich-text/src/RichTextInput.stories.tsx index e01b3b673ec..1027595555c 100644 --- a/packages/ra-input-rich-text/src/RichTextInput.stories.tsx +++ b/packages/ra-input-rich-text/src/RichTextInput.stories.tsx @@ -19,7 +19,7 @@ import { } from 'ra-ui-materialui'; import { useWatch } from 'react-hook-form'; import fakeRestDataProvider from 'ra-data-fakerest'; -import { Routes, Route } from 'react-router-dom'; +import { Routes, Route } from 'react-router'; import Mention from '@tiptap/extension-mention'; import { Editor, ReactRenderer } from '@tiptap/react'; import { computePosition, flip, shift, offset } from '@floating-ui/dom'; diff --git a/packages/ra-no-code/package.json b/packages/ra-no-code/package.json index f1c4e3e83ec..4874856ba04 100644 --- a/packages/ra-no-code/package.json +++ b/packages/ra-no-code/package.json @@ -30,7 +30,6 @@ "react": "^18.3.1", "react-dom": "^18.3.1", "react-router": "^6.22.0", - "react-router-dom": "^6.22.0", "typescript": "^5.1.3", "zshy": "^0.5.0" }, @@ -38,7 +37,8 @@ "@mui/icons-material": "^5.16.12 || ^6.0.0 || ^7.0.0 || ^9.0.0", "@mui/material": "^5.16.12 || ^6.0.0 || ^7.0.0 || ^9.0.0", "react": "^18.0.0 || ^19.0.0", - "react-dom": "^18.0.0 || ^19.0.0" + "react-dom": "^18.0.0 || ^19.0.0", + "react-router": "^6.28.1 || ^7.1.1 || ^8.0.0" }, "dependencies": { "@tanstack/react-query": "^5.83.0", diff --git a/packages/ra-no-code/src/ui/ImportResourceDialog.tsx b/packages/ra-no-code/src/ui/ImportResourceDialog.tsx index f7d864bd28a..0012ff7048b 100644 --- a/packages/ra-no-code/src/ui/ImportResourceDialog.tsx +++ b/packages/ra-no-code/src/ui/ImportResourceDialog.tsx @@ -13,7 +13,7 @@ import { useDropzone } from 'react-dropzone'; import { useQueryClient } from '@tanstack/react-query'; import { useNotify } from 'react-admin'; -import { useNavigate } from 'react-router-dom'; +import { useNavigate } from 'react-router'; import { useImportResourceFromCsv } from './useImportResourceFromCsv'; export const ImportResourceDialog = (props: ImportResourceDialogProps) => { diff --git a/packages/ra-no-code/src/ui/ResourceMenuItem.tsx b/packages/ra-no-code/src/ui/ResourceMenuItem.tsx index dfba4861e39..e054243c506 100644 --- a/packages/ra-no-code/src/ui/ResourceMenuItem.tsx +++ b/packages/ra-no-code/src/ui/ResourceMenuItem.tsx @@ -1,10 +1,9 @@ -import React, { forwardRef } from 'react'; +import React from 'react'; import { styled } from '@mui/material/styles'; -import { MenuItemLink, MenuItemLinkProps } from 'react-admin'; +import { LinkBase, MenuItemLink, MenuItemLinkProps } from 'react-admin'; import { IconButton } from '@mui/material'; import SettingsIcon from '@mui/icons-material/Settings'; import DefaultIcon from '@mui/icons-material/ViewList'; -import { NavLink, NavLinkProps } from 'react-router-dom'; import { ResourceConfiguration } from '../ResourceConfiguration'; const PREFIX = 'ResourceMenuItem'; @@ -48,7 +47,7 @@ export const ResourceMenuItem = ( {...rest} /> ); }; - -const NavLinkRef = forwardRef((props, ref) => ( - -)); diff --git a/packages/ra-router-react-router-next/README.md b/packages/ra-router-react-router-next/README.md new file mode 100644 index 00000000000..05b8d21e6f1 --- /dev/null +++ b/packages/ra-router-react-router-next/README.md @@ -0,0 +1,51 @@ +# ra-router-react-router-next + +[React Router v8](https://reactrouter.com) adapter for [react-admin](https://github.com/marmelab/react-admin). + +react-admin ships with a React Router v6/v7 adapter by default +([`ra-router-react-router`](../ra-router-react-router)). Use this package to run +react-admin on React Router v8. + +New projects are encouraged to use this adapter. The React Router v6/v7 default is +kept for backward compatibility, and **`ra-router-react-router-next` will become +`ra-router-react-router` in react-admin v6.** + +> **Note:** React Router v8 requires React 19. Make sure your application uses +> `react@^19.2.7` and `react-dom@^19.2.7`. + +## Installation + +```sh +npm install ra-router-react-router-next react-router@^8 +# or +yarn add ra-router-react-router-next react-router@^8 +``` + +## Usage + +Use the `reactRouterNextProvider` as the `routerProvider` prop on ``: + +```tsx +import { Admin, Resource } from 'react-admin'; +import { reactRouterNextProvider } from 'ra-router-react-router-next'; +import { dataProvider } from './dataProvider'; +import { PostList, PostEdit, PostCreate } from './posts'; + +export const App = () => ( + + + +); +``` + +By default the provider creates a hash router. When react-admin is rendered inside an +existing React Router context, it uses that router instead. + +## License + +MIT diff --git a/packages/ra-router-react-router-next/jest.config.cjs b/packages/ra-router-react-router-next/jest.config.cjs new file mode 100644 index 00000000000..58a59aa6b72 --- /dev/null +++ b/packages/ra-router-react-router-next/jest.config.cjs @@ -0,0 +1,67 @@ +const path = require('path'); +const fs = require('fs'); + +// Package-local jest config for ra-router-react-router-next, wired into the root +// config via `projects`. +// +// React Router v8 is ESM-only and uses `import.meta`, and it requires React 19. +// Neither fits the default CJS jest project, so this config: +// - runs in ESM mode (the root test scripts pass `NODE_OPTIONS=--experimental-vm-modules`), +// - transforms `react-router` (it ships untranspiled ESM) and emits ES modules, +// - forces React 19 (a devDependency of this package) as a single instance across +// the whole tree (ra-core included) to avoid duplicate-React hook errors. + +const repoRoot = path.resolve(__dirname, '../..'); + +const packages = fs.readdirSync(path.join(repoRoot, 'packages')); +const moduleNameMapper = packages.reduce((mapper, dirName) => { + const pkg = require( + path.join(repoRoot, 'packages', dirName, 'package.json') + ); + mapper[`^${pkg.name}(.*)$`] = path.join( + repoRoot, + `./packages/${dirName}/src$1` + ); + return mapper; +}, {}); + +const react19Dir = path.dirname( + require.resolve('react/package.json', { paths: [__dirname] }) +); +const reactDom19Dir = path.dirname( + require.resolve('react-dom/package.json', { paths: [__dirname] }) +); + +module.exports = { + rootDir: repoRoot, + roots: [__dirname], + globalSetup: '/test-global-setup.js', + setupFilesAfterEnv: ['/test-setup.js'], + testEnvironment: 'jsdom', + extensionsToTreatAsEsm: ['.ts', '.tsx'], + transformIgnorePatterns: [ + '[/\\\\]node_modules[/\\\\](?!(@hookform|react-hotkeys-hook|@faker-js/faker|react-router)/).+\\.(js|jsx|mjs|ts|tsx)$', + ], + transform: { + '^.+\\.[tj]sx?$': [ + 'ts-jest', + { + isolatedModules: true, + useESM: true, + // Emit ES modules so jest's ESM runtime can load them (the root + // tsconfig targets CommonJS, which would emit `exports.*`). + tsconfig: { + module: 'ESNext', + target: 'ESNext', + }, + }, + ], + }, + moduleNameMapper: { + '^react$': require.resolve('react', { paths: [__dirname] }), + '^react/(.*)$': path.join(react19Dir, '$1'), + '^react-dom$': require.resolve('react-dom', { paths: [__dirname] }), + '^react-dom/(.*)$': path.join(reactDom19Dir, '$1'), + ...moduleNameMapper, + }, +}; diff --git a/packages/ra-router-react-router-next/package.json b/packages/ra-router-react-router-next/package.json new file mode 100644 index 00000000000..fc94e553337 --- /dev/null +++ b/packages/ra-router-react-router-next/package.json @@ -0,0 +1,49 @@ +{ + "name": "ra-router-react-router-next", + "version": "5.14.7", + "description": "React Router v8 provider for react-admin (will be republished as ra-router-react-router in react-admin v6)", + "files": [ + "*.md", + "dist", + "src" + ], + "zshy": { + "exports": "./src/index.ts", + "cjs": false + }, + "main": "./dist/index.js", + "module": "./dist/index.js", + "types": "./dist/index.d.ts", + "type": "module", + "sideEffects": false, + "repository": "marmelab/react-admin", + "homepage": "https://github.com/marmelab/react-admin#readme", + "bugs": "https://github.com/marmelab/react-admin/issues", + "author": "François Zaninotto", + "license": "MIT", + "scripts": { + "build": "zshy --silent" + }, + "dependencies": { + "react-router": "^8.0.0" + }, + "devDependencies": { + "react": "^19.2.7", + "react-dom": "^19.2.7", + "react-router": "^8.0.0", + "typescript": "^5.1.3", + "zshy": "^0.5.0" + }, + "peerDependencies": { + "ra-core": "^5.0.0", + "react": "^19.2.7", + "react-dom": "^19.2.7", + "react-router": "^8.0.0" + }, + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + } + } +} diff --git a/packages/ra-router-react-router-next/src/index.ts b/packages/ra-router-react-router-next/src/index.ts new file mode 100644 index 00000000000..d88bfc0204b --- /dev/null +++ b/packages/ra-router-react-router-next/src/index.ts @@ -0,0 +1 @@ +export * from './reactRouterNextProvider'; diff --git a/packages/ra-router-react-router-next/src/reactRouterNextProvider.spec.tsx b/packages/ra-router-react-router-next/src/reactRouterNextProvider.spec.tsx new file mode 100644 index 00000000000..c1b7f9d0d42 --- /dev/null +++ b/packages/ra-router-react-router-next/src/reactRouterNextProvider.spec.tsx @@ -0,0 +1,1533 @@ +import * as React from 'react'; +import { + render, + screen, + fireEvent, + waitFor, + cleanup, +} from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { + Basic, + EmbeddedInReactRouter, + HistoryNavigation, + LinkComponent, + MultipleResources, + CustomRoutesSupport, + UseParamsTest, + UseMatchTest, + UseWarnWhenUnsavedChangesTest, + NavigateComponent, + UseLocationTest, + RouterContextTest, + NestedRoutesWithOutlet, + NestedResources, + QueryParameters, + PathlessLayoutRoutes, + NestedResourcesPrecedence, + PathlessLayoutRoutesPriority, + PathlessLayoutRoutesWithEmptyRoute, + PathlessLayoutRoutesWithIndexRoute, +} from './reactRouterNextProvider.stories'; +import { reactRouterNextProvider } from './reactRouterNextProvider'; + +const { matchPath } = reactRouterNextProvider; + +describe('reactRouterNextProvider', () => { + // Reset hash before each test to ensure clean state + beforeEach(() => { + window.location.hash = ''; + }); + + afterEach(() => { + cleanup(); + window.location.hash = ''; + }); + + describe('matchPath', () => { + describe('catch-all patterns', () => { + it('should match "*" against any path', () => { + expect(matchPath('*', '/anything')).toMatchObject({ + params: { '*': 'anything' }, + pathname: '/anything', + pathnameBase: '/', + }); + }); + + it('should match "*" against root path', () => { + expect(matchPath('*', '/')).toMatchObject({ + params: { '*': '' }, + pathname: '/', + pathnameBase: '/', + }); + }); + + it('should match "/*" against root path', () => { + expect(matchPath('/*', '/')).toMatchObject({ + params: { '*': '' }, + pathname: '/', + pathnameBase: '/', + }); + }); + + it('should match "/*" against nested path', () => { + expect(matchPath('/*', '/posts/1/show')).toMatchObject({ + params: { '*': 'posts/1/show' }, + pathname: '/posts/1/show', + pathnameBase: '/', + }); + }); + }); + + describe('root/empty paths', () => { + it('should match "/" against "/"', () => { + expect(matchPath('/', '/')).toMatchObject({ + params: {}, + pathname: '/', + pathnameBase: '/', + }); + }); + + it('should match "" against "/"', () => { + expect(matchPath('', '/')).toMatchObject({ + params: {}, + pathname: '/', + pathnameBase: '/', + }); + }); + + it('should not match "" against ""', () => { + expect(matchPath('', '')).toBeNull(); + }); + + it('should not match "/" against "/posts" by default (end=true)', () => { + expect(matchPath('/', '/posts')).toBeNull(); + }); + + it('should match "/" against "/posts" with end=false', () => { + expect( + matchPath({ path: '/', end: false }, '/posts') + ).toMatchObject({ + params: {}, + pathname: '/', + pathnameBase: '/', + }); + }); + }); + + describe('static paths', () => { + it('should match exact static path', () => { + expect(matchPath('/posts', '/posts')).toMatchObject({ + params: {}, + pathname: '/posts', + pathnameBase: '/posts', + }); + }); + + it('should match static path with trailing slash in pathname', () => { + expect(matchPath('/posts', '/posts/')).toMatchObject({ + params: {}, + pathname: '/posts/', + pathnameBase: '/posts', + }); + }); + + it('should not match static path against longer path by default', () => { + expect(matchPath('/posts', '/posts/1')).toBeNull(); + }); + + it('should match static path as prefix with end=false', () => { + expect( + matchPath({ path: '/posts', end: false }, '/posts/1') + ).toMatchObject({ + params: {}, + pathname: '/posts', + pathnameBase: '/posts', + }); + }); + + it('should match nested static path', () => { + expect( + matchPath('/users/settings', '/users/settings') + ).toMatchObject({ + params: {}, + pathname: '/users/settings', + pathnameBase: '/users/settings', + }); + }); + + it('should not match different static path', () => { + expect(matchPath('/posts', '/comments')).toBeNull(); + }); + }); + + describe('dynamic params', () => { + it('should match single param', () => { + expect(matchPath('/posts/:id', '/posts/123')).toMatchObject({ + params: { id: '123' }, + pathname: '/posts/123', + pathnameBase: '/posts/123', + }); + }); + + it('should match multiple params', () => { + expect( + matchPath( + '/users/:userId/posts/:postId', + '/users/1/posts/2' + ) + ).toMatchObject({ + params: { userId: '1', postId: '2' }, + pathname: '/users/1/posts/2', + pathnameBase: '/users/1/posts/2', + }); + }); + + it('should match param with special characters in value', () => { + expect( + matchPath('/posts/:id', '/posts/hello-world') + ).toMatchObject({ + params: { id: 'hello-world' }, + pathname: '/posts/hello-world', + pathnameBase: '/posts/hello-world', + }); + }); + + it('should not match param when segment is missing', () => { + expect(matchPath('/posts/:id', '/posts')).toBeNull(); + expect(matchPath('/posts/:id', '/posts/')).toBeNull(); + }); + + it('should match param at root level', () => { + expect(matchPath('/:resource', '/posts')).toMatchObject({ + params: { resource: 'posts' }, + pathname: '/posts', + pathnameBase: '/posts', + }); + }); + + it('should decode only path separator in URL-encoded params', () => { + // UTF-8 characters: 衣類/衣類 encoded + expect( + matchPath( + '/comments/:id', + '/comments/%E8%A1%A3%E9%A1%9E%2F%E8%A1%A3%E9%A1%9E' + ) + ).toMatchObject({ + params: { id: '%E8%A1%A3%E9%A1%9E/%E8%A1%A3%E9%A1%9E' }, + pathname: + '/comments/%E8%A1%A3%E9%A1%9E%2F%E8%A1%A3%E9%A1%9E', + }); + }); + + it('should keep percent-encoded spaces in params', () => { + expect( + matchPath('/posts/:id', '/posts/hello%20world') + ).toMatchObject({ + params: { id: 'hello%20world' }, + pathname: '/posts/hello%20world', + pathnameBase: '/posts/hello%20world', + }); + }); + }); + + describe('splat patterns (path/*)', () => { + it('should match splat with content', () => { + expect(matchPath('/posts/*', '/posts/1/show')).toMatchObject({ + params: { '*': '1/show' }, + pathname: '/posts/1/show', + pathnameBase: '/posts', + }); + }); + + it('should match splat at root of pattern', () => { + expect(matchPath('/posts/*', '/posts')).toMatchObject({ + params: { '*': '' }, + pathname: '/posts', + pathnameBase: '/posts', + }); + }); + + it('should match splat with trailing slash', () => { + expect(matchPath('/posts/*', '/posts/')).toMatchObject({ + params: { '*': '' }, + pathname: '/posts/', + pathnameBase: '/posts', + }); + }); + + it('should match splat with deeply nested path', () => { + expect( + matchPath('/admin/*', '/admin/users/1/edit') + ).toMatchObject({ + params: { '*': 'users/1/edit' }, + pathname: '/admin/users/1/edit', + pathnameBase: '/admin', + }); + }); + + it('should decode only path separator in URL-encoded splat values', () => { + expect( + matchPath('/files/*', '/files/path%2Fto%2Ffile%20name.txt') + ).toMatchObject({ + params: { '*': 'path/to/file%20name.txt' }, + pathname: '/files/path%2Fto%2Ffile%20name.txt', + pathnameBase: '/files', + }); + }); + }); + + describe('combined params and splat', () => { + it('should match param followed by splat', () => { + expect( + matchPath('/:resource/*', '/posts/1/show') + ).toMatchObject({ + params: { resource: 'posts', '*': '1/show' }, + pathname: '/posts/1/show', + pathnameBase: '/posts', + }); + }); + + it('should match multiple params with splat', () => { + expect( + matchPath('/:resource/:id/*', '/posts/1/comments/2') + ).toMatchObject({ + params: { resource: 'posts', id: '1', '*': 'comments/2' }, + pathname: '/posts/1/comments/2', + pathnameBase: '/posts/1', + }); + }); + + it('should match param and empty splat', () => { + expect(matchPath('/:resource/*', '/posts')).toMatchObject({ + params: { resource: 'posts', '*': '' }, + pathname: '/posts', + pathnameBase: '/posts', + }); + }); + }); + + describe('ReDoS avoidance and edge cases', () => { + it('should handle long paths efficiently', () => { + const longPath = + '/a/b/c/d/e/f/g/h/i/j/k/l/m/n/o/p/q/r/s/t/u/v/w/x/y/z'; + const pattern = + '/a/b/c/d/e/f/g/h/i/j/k/l/m/n/o/p/q/r/s/t/u/v/w/x/y/z'; + expect(matchPath(pattern, longPath)).not.toBeNull(); + }); + + it('should handle long paths with mismatch at the end efficiently', () => { + const longPath = + '/a/b/c/d/e/f/g/h/i/j/k/l/m/n/o/p/q/r/s/t/u/v/w/x/y/z/mismatch'; + const pattern = + '/a/b/c/d/e/f/g/h/i/j/k/l/m/n/o/p/q/r/s/t/u/v/w/x/y/z/match'; + expect(matchPath(pattern, longPath)).toBeNull(); + }); + + it('should not match a path with collapsed multiple slashes', () => { + expect(matchPath('/a/b', '///a///b///')).toBeNull(); + }); + + it('should handle special characters in path segments', () => { + expect( + matchPath('/files/:filename', '/files/image.png') + ).toMatchObject({ + params: { filename: 'image.png' }, + pathname: '/files/image.png', + pathnameBase: '/files/image.png', + }); + + expect( + matchPath('/search/:query', '/search/foo+bar%20baz') + ).toMatchObject({ + params: { query: 'foo+bar%20baz' }, + pathname: '/search/foo+bar%20baz', + pathnameBase: '/search/foo+bar%20baz', + }); + }); + }); + + describe('end option', () => { + it('should match exact path when end=true (default)', () => { + expect(matchPath('/posts', '/posts')).not.toBeNull(); + expect(matchPath('/posts', '/posts/1')).toBeNull(); + }); + + it('should match prefix when end=false', () => { + expect( + matchPath({ path: '/posts', end: false }, '/posts/1/show') + ).toMatchObject({ + params: {}, + pathname: '/posts', + pathnameBase: '/posts', + }); + }); + + it('should match param prefix when end=false', () => { + expect( + matchPath( + { path: '/posts/:id', end: false }, + '/posts/1/comments' + ) + ).toMatchObject({ + params: { id: '1' }, + pathname: '/posts/1', + pathnameBase: '/posts/1', + }); + }); + + it('should use end=true when pattern is string', () => { + expect(matchPath('/posts', '/posts/1')).toBeNull(); + }); + + it('should use end=true when end is not specified in object', () => { + expect(matchPath({ path: '/posts' }, '/posts/1')).toBeNull(); + }); + }); + + describe('paths without leading slash', () => { + it('should normalize path without leading slash', () => { + expect(matchPath('posts', '/posts')).toMatchObject({ + params: {}, + pathname: '/posts', + pathnameBase: '/posts', + }); + }); + + it('should normalize param path without leading slash', () => { + expect(matchPath('posts/:id', '/posts/1')).toMatchObject({ + params: { id: '1' }, + pathname: '/posts/1', + pathnameBase: '/posts/1', + }); + }); + }); + + describe('trailing slashes', () => { + it('should match path with trailing slash in pattern', () => { + expect(matchPath('/posts/', '/posts')).toMatchObject({ + params: {}, + pathname: '/posts', + pathnameBase: '/posts', + }); + }); + + it('should match path with trailing slash in pathname', () => { + expect(matchPath('/posts', '/posts/')).toMatchObject({ + params: {}, + pathname: '/posts/', + pathnameBase: '/posts', + }); + }); + + it('should match when both have trailing slash', () => { + expect(matchPath('/posts/', '/posts/')).toMatchObject({ + params: {}, + pathname: '/posts/', + pathnameBase: '/posts', + }); + }); + }); + + describe('special regex characters in path', () => { + it('should escape dots in path', () => { + expect(matchPath('/api/v1.0', '/api/v1.0')).toMatchObject({ + params: {}, + pathname: '/api/v1.0', + pathnameBase: '/api/v1.0', + }); + }); + + it('should not match dot as wildcard', () => { + expect(matchPath('/api/v1.0', '/api/v1X0')).toBeNull(); + }); + }); + + describe('react-admin resource patterns', () => { + it('should match resource list pattern', () => { + expect(matchPath('/:resource/*', '/posts')).toMatchObject({ + params: { resource: 'posts', '*': '' }, + pathname: '/posts', + pathnameBase: '/posts', + }); + }); + + it('should match resource show pattern', () => { + expect( + matchPath('/:resource/:id/show', '/posts/1/show') + ).toMatchObject({ + params: { resource: 'posts', id: '1' }, + pathname: '/posts/1/show', + pathnameBase: '/posts/1/show', + }); + }); + + it('should match resource edit pattern', () => { + expect(matchPath('/:resource/:id', '/posts/1')).toMatchObject({ + params: { resource: 'posts', id: '1' }, + pathname: '/posts/1', + pathnameBase: '/posts/1', + }); + }); + + it('should match resource create pattern', () => { + expect( + matchPath('/:resource/create', '/posts/create') + ).toMatchObject({ + params: { resource: 'posts' }, + pathname: '/posts/create', + pathnameBase: '/posts/create', + }); + }); + }); + + describe('basename scenarios', () => { + it('should match path after basename is stripped', () => { + // When basename is /admin, the pathname passed to matchPath + // should already have basename stripped (this is done by Routes) + expect(matchPath('/posts', '/posts')).toMatchObject({ + params: {}, + pathname: '/posts', + pathnameBase: '/posts', + }); + }); + + it('should match root after basename is stripped', () => { + // After stripping /admin from /admin, we get / + expect(matchPath('/', '/')).toMatchObject({ + params: {}, + pathname: '/', + pathnameBase: '/', + }); + }); + + it('should match catch-all after basename is stripped', () => { + // /admin/posts/1 with basename /admin becomes /posts/1 + expect(matchPath('/*', '/posts/1')).toMatchObject({ + params: { '*': 'posts/1' }, + pathname: '/posts/1', + pathnameBase: '/', + }); + }); + + it('should match nested resource after basename is stripped', () => { + // /admin/posts/1/show with basename /admin becomes /posts/1/show + expect( + matchPath('/:resource/:id/show', '/posts/1/show') + ).toMatchObject({ + params: { resource: 'posts', id: '1' }, + pathname: '/posts/1/show', + pathnameBase: '/posts/1/show', + }); + }); + }); + }); + + describe('RouterWrapper', () => { + describe('standalone mode', () => { + it('should render the post list', async () => { + render(); + await waitFor(() => { + expect(screen.getByText('Posts')).toBeInTheDocument(); + }); + }); + + it('should display the current location', async () => { + render(); + await waitFor(() => { + expect( + screen.getByText('Current Location:') + ).toBeInTheDocument(); + }); + }); + }); + + describe('embedded mode', () => { + it('should render home page initially', async () => { + render(); + await waitFor(() => { + expect(screen.getByText('Home Page')).toBeInTheDocument(); + expect( + screen.getByText( + 'This is a React Router app with embedded react-admin.' + ) + ).toBeInTheDocument(); + }); + }); + + it('should navigate to admin section', async () => { + render(); + await waitFor(() => { + expect(screen.getByText('Admin')).toBeInTheDocument(); + }); + + fireEvent.click(screen.getByText('Admin')); + + await waitFor( + () => { + expect(screen.getByText('Posts')).toBeInTheDocument(); + }, + { timeout: 3000 } + ); + }); + + it('should navigate back to parent app', async () => { + const user = userEvent.setup(); + render(); + await waitFor(() => { + expect(screen.getByText('Admin')).toBeInTheDocument(); + }); + + await user.click(screen.getByText('Admin')); + + await waitFor( + () => { + expect(screen.getByText('Posts')).toBeInTheDocument(); + }, + { timeout: 3000 } + ); + + // Navigate back to home via hash change + window.location.hash = '#/'; + window.dispatchEvent(new HashChangeEvent('hashchange')); + + await waitFor( + () => { + expect( + screen.getByText('Home Page') + ).toBeInTheDocument(); + }, + { timeout: 3000 } + ); + }); + }); + }); + + describe('useNavigate', () => { + it('should navigate to a path programmatically', async () => { + render(); + await waitFor(() => { + expect(screen.getByText('Create')).toBeInTheDocument(); + }); + + fireEvent.click(screen.getByText('Create')); + + await waitFor(() => { + expect(screen.getByText('Create Post')).toBeInTheDocument(); + }); + }); + + it('should navigate back in history with navigate(-1)', async () => { + render(); + + await screen.findByText('Post #1'); + + fireEvent.click(screen.getByText('Post #1')); + + await waitFor(() => { + expect(screen.getByText('Post Details')).toBeInTheDocument(); + }); + + fireEvent.click(screen.getByText('← Back')); + + await waitFor(() => { + expect(screen.getByText('Posts')).toBeInTheDocument(); + }); + }); + + it('should navigate within nested routes', async () => { + render(); + await waitFor(() => { + expect(screen.getByText('Admin')).toBeInTheDocument(); + }); + + fireEvent.click(screen.getByText('Admin')); + + await screen.findByText('Posts'); + + // Wait for data to load before clicking + await screen.findByText('Post #1'); + + fireEvent.click(screen.getByText('Post #1')); + + await waitFor( + () => { + expect( + screen.getByText('Post Details') + ).toBeInTheDocument(); + }, + { timeout: 3000 } + ); + }); + }); + + describe('Link', () => { + it('should render as an anchor element', async () => { + render(); + await waitFor(() => { + expect(screen.getByText('Post #1')).toBeInTheDocument(); + }); + expect(screen.getByText('Post #1').tagName).toBe('A'); + }); + + it('should navigate when clicked', async () => { + render(); + await waitFor(() => { + expect(screen.getByText('Post #1')).toBeInTheDocument(); + }); + + fireEvent.click(screen.getByText('Post #1')); + + await waitFor( + () => { + expect( + screen.getByText('Post Details') + ).toBeInTheDocument(); + }, + { timeout: 3000 } + ); + }); + + it('should support replace prop', async () => { + render(); + await waitFor(() => { + expect( + screen.getByText('Go to Post #2 (replace history)') + ).toBeInTheDocument(); + }); + }); + + it('should support state prop', async () => { + render(); + await waitFor(() => { + expect( + screen.getByText('Go to Post #3 (with state)') + ).toBeInTheDocument(); + }); + }); + + it('should support location object with pathname and search', async () => { + render(); + await waitFor(() => { + expect( + screen.getByText('Go to Post #4 (with search)') + ).toBeInTheDocument(); + }); + + fireEvent.click(screen.getByText('Go to Post #4 (with search)')); + + await waitFor(() => { + expect(screen.getByText('Post Details')).toBeInTheDocument(); + }); + // Check that search params are preserved in location + expect( + screen.getByText(/"search": "\?foo=bar"/) + ).toBeInTheDocument(); + }); + + it('should support location object with only search (no pathname)', async () => { + render(); + await waitFor(() => { + expect( + screen.getByText('Go to same page with search param') + ).toBeInTheDocument(); + }); + + fireEvent.click( + screen.getByText('Go to same page with search param') + ); + + await waitFor(() => { + // Should stay on the same page (Link Tests page) + expect( + screen.getByText('Link Component Tests') + ).toBeInTheDocument(); + }); + // Check that search params are added + expect( + screen.getByText(/"search": "\?foo=bar"/) + ).toBeInTheDocument(); + }); + }); + + describe('Routes', () => { + describe('resource routes', () => { + it('should match list routes', async () => { + render(); + await waitFor(() => { + expect(screen.getByText('Posts')).toBeInTheDocument(); + }); + }); + + it('should match show routes', async () => { + render(); + await waitFor(() => { + expect(screen.getByText('Post #1')).toBeInTheDocument(); + }); + + fireEvent.click(screen.getByText('Post #1')); + + await waitFor( + () => { + expect( + screen.getByText('Post Details') + ).toBeInTheDocument(); + }, + { timeout: 3000 } + ); + }); + + it('should navigate between resources', async () => { + render(); + await waitFor(() => { + expect(screen.getByText('Posts')).toBeInTheDocument(); + }); + + fireEvent.click(screen.getByText('Go to Comments')); + + await waitFor(() => { + expect(screen.getByText('Comments')).toBeInTheDocument(); + }); + + fireEvent.click(screen.getByText('Go to Posts')); + + await waitFor(() => { + expect(screen.getByText('Posts')).toBeInTheDocument(); + }); + }); + }); + + describe('custom routes', () => { + it('should render custom routes with layout', async () => { + render(); + await waitFor(() => { + expect( + screen.getByText('Go to Custom Page') + ).toBeInTheDocument(); + }); + + fireEvent.click(screen.getByText('Go to Custom Page')); + + await waitFor(() => { + expect(screen.getByText('Custom Page')).toBeInTheDocument(); + expect( + screen.getByText( + "This is a custom route using react-router's Route component." + ) + ).toBeInTheDocument(); + }); + }); + + it('should render custom routes without layout', async () => { + render(); + await waitFor(() => { + expect( + screen.getByText('Go to Custom Page (No Layout)') + ).toBeInTheDocument(); + }); + + fireEvent.click( + screen.getByText('Go to Custom Page (No Layout)') + ); + + await waitFor(() => { + expect( + screen.getByText('Custom Page (No Layout)') + ).toBeInTheDocument(); + expect( + screen.getByText( + 'This page renders outside the layout.' + ) + ).toBeInTheDocument(); + }); + }); + + it('should navigate from custom route back to resource', async () => { + render(); + await waitFor(() => { + expect( + screen.getByText('Go to Custom Page') + ).toBeInTheDocument(); + }); + + fireEvent.click(screen.getByText('Go to Custom Page')); + + await waitFor(() => { + expect(screen.getByText('Custom Page')).toBeInTheDocument(); + }); + + fireEvent.click(screen.getByText('Go to Posts')); + + await waitFor(() => { + expect(screen.getByText('Posts')).toBeInTheDocument(); + }); + }); + }); + }); + + describe('useParams', () => { + it('should not have id param on list page', async () => { + render(); + await waitFor(() => { + expect(screen.getByText('Posts')).toBeInTheDocument(); + }); + + const paramsDisplay = screen.getByTestId('params-display'); + expect(paramsDisplay.textContent).not.toContain('"id"'); + }); + + it('should return id param on show page', async () => { + render(); + await waitFor(() => { + expect(screen.getByText('Post #1')).toBeInTheDocument(); + }); + + fireEvent.click(screen.getByText('Post #1')); + + await waitFor( + () => { + expect( + screen.getByText('Post Details') + ).toBeInTheDocument(); + }, + { timeout: 3000 } + ); + + const paramsDisplay = screen.getByTestId('params-display'); + expect(paramsDisplay.textContent).toContain('"id"'); + expect(paramsDisplay.textContent).toContain('"1"'); + }); + + it('should return different id param for different records', async () => { + render(); + await waitFor(() => { + expect(screen.getByText('Post #2')).toBeInTheDocument(); + }); + + fireEvent.click(screen.getByText('Post #2')); + + await waitFor( + () => { + expect( + screen.getByText('Post Details') + ).toBeInTheDocument(); + }, + { timeout: 3000 } + ); + + const paramsDisplay = screen.getByTestId('params-display'); + expect(paramsDisplay.textContent).toContain('"id"'); + expect(paramsDisplay.textContent).toContain('"2"'); + }); + }); + + describe('useMatch', () => { + it('should match current route with end=false', async () => { + render(); + await waitFor(() => { + expect(screen.getByText('Posts List')).toBeInTheDocument(); + }); + + expect(screen.getByTestId('posts-match').textContent).toContain( + 'MATCH' + ); + expect(screen.getByTestId('comments-match').textContent).toContain( + 'no match' + ); + }); + + it('should match exact route with end=true', async () => { + render(); + await waitFor(() => { + expect(screen.getByText('Posts List')).toBeInTheDocument(); + }); + + expect( + screen.getByTestId('posts-exact-match').textContent + ).toContain('MATCH'); + }); + + it('should not match exact route on nested path', async () => { + render(); + await waitFor(() => { + expect(screen.getByText('Post #1')).toBeInTheDocument(); + }); + + fireEvent.click(screen.getByText('Post #1')); + + await waitFor( + () => { + expect(screen.getByText('Post Show')).toBeInTheDocument(); + }, + { timeout: 3000 } + ); + + // end=false should still match /posts on /posts/1/show + expect(screen.getByTestId('posts-match').textContent).toContain( + 'MATCH' + ); + // end=true should NOT match /posts on /posts/1/show + expect( + screen.getByTestId('posts-exact-match').textContent + ).toContain('no match'); + }); + + it('should update match when navigating to different resource', async () => { + render(); + await waitFor(() => { + expect(screen.getByText('Posts List')).toBeInTheDocument(); + }); + + fireEvent.click(screen.getByText('Comments')); + + await waitFor(() => { + expect(screen.getByText('Comments List')).toBeInTheDocument(); + }); + + expect(screen.getByTestId('posts-match').textContent).toContain( + 'no match' + ); + expect(screen.getByTestId('comments-match').textContent).toContain( + 'MATCH' + ); + }); + }); + + describe('useWarnWhenUnsavedChanges', () => { + it('should block navigation when form is dirty', async () => { + const originalConfirm = window.confirm; + let confirmCalled = false; + window.confirm = () => { + confirmCalled = true; + return false; + }; + try { + const user = userEvent.setup(); + render(); + const [title] = await screen.findAllByRole('textbox'); + await user.type(title, 'A new title'); + fireEvent.click(screen.getByText('Go to Comments')); + await waitFor(() => { + expect(confirmCalled).toBe(true); + }); + } finally { + window.confirm = originalConfirm; + } + }); + + it('should allow navigation when clicking proceed', async () => { + const originalConfirm = window.confirm; + window.confirm = () => true; + try { + const user = userEvent.setup(); + render(); + const [title] = await screen.findAllByRole('textbox'); + await user.type(title, 'A new title'); + fireEvent.click(screen.getByText('Go to Comments')); + await waitFor(() => { + expect( + screen.getByText('You navigated away from the form.') + ).toBeInTheDocument(); + }); + } finally { + window.confirm = originalConfirm; + } + }); + + it('should cancel navigation when clicking cancel', async () => { + const originalConfirm = window.confirm; + window.confirm = () => false; + try { + const user = userEvent.setup(); + render(); + const [title] = await screen.findAllByRole('textbox'); + await user.type(title, 'A new title'); + fireEvent.click(screen.getByText('Go to Comments')); + await waitFor(() => { + expect( + screen.getByText('Form with Unsaved Changes Warning') + ).toBeInTheDocument(); + }); + expect( + screen.getByDisplayValue('A new title') + ).toBeInTheDocument(); + } finally { + window.confirm = originalConfirm; + } + }); + + it('should not block navigation when form is not dirty', async () => { + const originalConfirm = window.confirm; + let confirmCalled = false; + window.confirm = () => { + confirmCalled = true; + return true; + }; + try { + render(); + await screen.findByText('Form with Unsaved Changes Warning'); + fireEvent.click(screen.getByText('Go to Comments')); + await waitFor(() => { + expect( + screen.getByText('You navigated away from the form.') + ).toBeInTheDocument(); + }); + expect(confirmCalled).toBe(false); + } finally { + window.confirm = originalConfirm; + } + }); + }); + + describe('Navigate', () => { + it('should redirect to target route', async () => { + render(); + await waitFor(() => { + expect(screen.getByTestId('posts-page')).toBeInTheDocument(); + }); + + fireEvent.click( + screen.getByText('Go to Redirect Page (auto-redirects here)') + ); + + // Should immediately redirect back to posts + await waitFor(() => { + expect(screen.getByTestId('posts-page')).toBeInTheDocument(); + }); + }); + + it('should preserve search params on redirect', async () => { + render(); + await waitFor(() => { + expect(screen.getByTestId('posts-page')).toBeInTheDocument(); + }); + + fireEvent.click(screen.getByText('Go to redirect with params')); + + // Should immediately redirect back to posts with search params + await waitFor(() => { + expect(screen.getByTestId('posts-page')).toBeInTheDocument(); + }); + + expect( + screen.getByText(/"pathname": "\/posts"/) + ).toBeInTheDocument(); + expect( + screen.getByText(/"search": "\?foo=bar"/) + ).toBeInTheDocument(); + }); + + it('should redirect conditionally when state changes', async () => { + render(); + await waitFor(() => { + expect(screen.getByTestId('posts-page')).toBeInTheDocument(); + }); + + fireEvent.click(screen.getByText('Go to Conditional Redirect')); + + await waitFor(() => { + expect( + screen.getByText('Conditional Redirect') + ).toBeInTheDocument(); + }); + + fireEvent.click(screen.getByTestId('trigger-redirect')); + + await waitFor(() => { + expect(screen.getByTestId('posts-page')).toBeInTheDocument(); + }); + }); + + it('should support location object with only search (no pathname)', async () => { + render(); + await waitFor(() => { + expect(screen.getByTestId('posts-page')).toBeInTheDocument(); + }); + + // Navigate to the Navigate search-only test page + // This page uses + // which should stay on the same pathname but add search params + fireEvent.click( + screen.getByText('Go to Navigate search-only test') + ); + + // Should show the success message after redirecting + await waitFor(() => { + expect( + screen.getByTestId('navigate-search-only-page') + ).toBeInTheDocument(); + }); + + // Should stay on /navigate-search-only but with search params added + expect( + screen.getByText(/"pathname": "\/navigate-search-only"/) + ).toBeInTheDocument(); + + // The search params should contain 'redirected' + expect( + screen.getByText(/"search": "\?redirected=true"/) + ).toBeInTheDocument(); + }); + }); + + describe('useLocation', () => { + it('should return current pathname', async () => { + render(); + await waitFor(() => { + expect(screen.getByText('Location Test')).toBeInTheDocument(); + }); + + expect( + screen.getByTestId('location-pathname').textContent + ).toContain('/posts'); + }); + + it('should return empty search by default', async () => { + render(); + await waitFor(() => { + expect(screen.getByText('Location Test')).toBeInTheDocument(); + }); + + expect(screen.getByTestId('location-search').textContent).toContain( + '""' + ); + }); + + it('should update pathname on navigation', async () => { + render(); + await waitFor(() => { + expect(screen.getByText('Go to Post Show')).toBeInTheDocument(); + }); + + fireEvent.click(screen.getByText('Go to Post Show')); + + await waitFor(() => { + expect(screen.getByText('Post Show')).toBeInTheDocument(); + }); + + expect( + screen.getByTestId('location-pathname').textContent + ).toContain('/posts/1/show'); + }); + + it('should include state when navigated with state', async () => { + render(); + await waitFor(() => { + expect( + screen.getByText('Go to Post Show (with state)') + ).toBeInTheDocument(); + }); + + fireEvent.click(screen.getByText('Go to Post Show (with state)')); + + await waitFor(() => { + expect(screen.getByText('Post Show')).toBeInTheDocument(); + }); + + expect(screen.getByTestId('location-state').textContent).toContain( + 'from' + ); + expect(screen.getByTestId('location-state').textContent).toContain( + 'list' + ); + }); + }); + + describe('useInRouterContext', () => { + it('should return true when inside router', async () => { + render(); + await waitFor(() => { + expect( + screen.getByText('Router Context Test') + ).toBeInTheDocument(); + }); + + expect( + screen.getByTestId('in-router-context').textContent + ).toContain('true'); + }); + }); + + describe('useCanBlock', () => { + it('should return true for React Router', async () => { + render(); + await waitFor(() => { + expect( + screen.getByText('Router Context Test') + ).toBeInTheDocument(); + }); + + expect(screen.getByTestId('can-block').textContent).toContain( + 'true' + ); + }); + }); + + describe('Nested Routes with Outlet', () => { + it('should render the default tab content', async () => { + render(); + await screen.findByText('Post #1'); + + fireEvent.click(screen.getByText('Post #1')); + await screen.findByText('Tabbed Layout (like TabbedShowLayout)'); + + // Should render the first tab (content) by default + expect(screen.getByTestId('content-tab')).toBeInTheDocument(); + expect( + screen.getByText( + 'This is the content tab (first tab, default).' + ) + ).toBeInTheDocument(); + }); + + it('should navigate between tabs using Outlet', async () => { + render(); + await screen.findByText('Post #1'); + + fireEvent.click(screen.getByText('Post #1')); + await screen.findByTestId('content-tab'); + + // Click on the second tab (Metadata) + fireEvent.click(screen.getByText('Metadata Tab')); + await screen.findByTestId('metadata-tab'); + + expect( + screen.getByText('This is the metadata tab (second tab).') + ).toBeInTheDocument(); + expect(screen.queryByTestId('content-tab')).not.toBeInTheDocument(); + }); + + it('should navigate back to first tab', async () => { + render(); + await waitFor(() => { + expect(screen.getByText('Post #1')).toBeInTheDocument(); + }); + + fireEvent.click(screen.getByText('Post #1')); + + await waitFor( + () => { + expect( + screen.getByTestId('content-tab') + ).toBeInTheDocument(); + }, + { timeout: 3000 } + ); + + // Go to second tab + fireEvent.click(screen.getByText('Metadata Tab')); + + await waitFor(() => { + expect(screen.getByTestId('metadata-tab')).toBeInTheDocument(); + }); + + // Go back to first tab + fireEvent.click(screen.getByText('Content Tab')); + + await waitFor(() => { + expect(screen.getByTestId('content-tab')).toBeInTheDocument(); + }); + + expect( + screen.queryByTestId('metadata-tab') + ).not.toBeInTheDocument(); + }); + }); + + describe('Nested Resources (Route children of Resource)', () => { + it('should navigate to child routes defined inside Resource', async () => { + render(); + await screen.findByText('Post #1'); + + fireEvent.click(screen.getByText('Post #1')); + + await waitFor( + () => { + expect( + screen.getByText('Post Details') + ).toBeInTheDocument(); + }, + { timeout: 3000 } + ); + }); + }); + + describe('Query Parameters', () => { + it('should update URL with query parameters when sorting', async () => { + render(); + await screen.findByText('Posts with Query Parameters'); + + // Initially no search params + expect(screen.getByTestId('current-search').textContent).toContain( + '(empty)' + ); + + // Click sort by title + fireEvent.click(screen.getByTestId('sort-title')); + + await waitFor(() => { + expect( + screen.getByTestId('current-search').textContent + ).toContain('sort=title'); + }); + + expect(screen.getByTestId('current-sort').textContent).toContain( + 'title' + ); + }); + + it('should update URL with query parameters when changing page', async () => { + render(); + await screen.findByText('Posts with Query Parameters'); + + // Click page 2 + fireEvent.click(screen.getByTestId('page-2')); + + await waitFor(() => { + expect( + screen.getByTestId('current-search').textContent + ).toContain('page=2'); + }); + + expect(screen.getByTestId('current-page').textContent).toContain( + '2' + ); + }); + + it('should preserve query parameters across multiple updates', async () => { + render(); + await screen.findByText('Posts with Query Parameters'); + + // Set sort + fireEvent.click(screen.getByTestId('sort-title')); + + await waitFor(() => { + expect( + screen.getByTestId('current-search').textContent + ).toContain('sort=title'); + }); + + // Set page + fireEvent.click(screen.getByTestId('page-3')); + + await waitFor(() => { + const search = + screen.getByTestId('current-search').textContent || ''; + expect(search).toContain('sort=title'); + expect(search).toContain('page=3'); + }); + }); + }); + + describe('Pathless Layout Routes', () => { + it('should match pathless layout routes with child routes', async () => { + window.location.hash = '#/posts'; + + render(); + + await waitFor(() => { + expect(screen.getByText('Layout Wrapper')).toBeInTheDocument(); + expect(screen.getByTestId('posts-page')).toBeInTheDocument(); + }); + }); + + it('should navigate between child routes within pathless layout', async () => { + window.location.hash = '#/posts'; + + render(); + + await waitFor(() => { + expect(screen.getByText('Layout Wrapper')).toBeInTheDocument(); + expect(screen.getByTestId('posts-page')).toBeInTheDocument(); + }); + + fireEvent.click(screen.getByText('Comments')); + + await waitFor(() => { + expect(screen.getByText('Layout Wrapper')).toBeInTheDocument(); + expect(screen.getByTestId('comments-page')).toBeInTheDocument(); + }); + }); + + it('should match the most specific layout route within pathless layout routes', async () => { + window.location.hash = '#/posts'; + + render(); + + await waitFor(() => { + expect(screen.getByTestId('posts-page')).toBeInTheDocument(); + }); + + fireEvent.click(screen.getByText('User')); + + await waitFor(() => { + expect(screen.getByTestId('users-page')).toBeInTheDocument(); + }); + + fireEvent.click(screen.getByText('Block a user')); + + await waitFor(() => { + expect( + screen.getByTestId('block-user-page') + ).toBeInTheDocument(); + }); + }); + }); + + it('should match the empty path route as most specific within pathless layout routes', async () => { + window.location.hash = '#/posts'; + + render(); + + await waitFor(() => { + expect(screen.getByTestId('posts-page')).toBeInTheDocument(); + }); + + fireEvent.click(screen.getByText('Home (path="")')); + + await waitFor(() => { + expect(screen.getByTestId('home-page')).toBeInTheDocument(); + }); + }); + + it('should match the index route as most specific within pathless layout routes', async () => { + window.location.hash = '#/posts'; + + render(); + + await waitFor(() => { + expect(screen.getByTestId('posts-page')).toBeInTheDocument(); + }); + + fireEvent.click(screen.getByText('Home (index)')); + + await waitFor(() => { + expect(screen.getByTestId('home-page')).toBeInTheDocument(); + }); + }); + + describe('Resource Children (Route as children of Resource)', () => { + it('should navigate to child routes without matching parent edit route', async () => { + render(); + + // Wait for posts list to load + await screen.findByText('Post #1'); + + // Click on a post to go to edit page + fireEvent.click(screen.getByText('Post #1')); + + // Wait for edit page + await screen.findByText('Post Details'); + + // Click to view comments (child route) + fireEvent.click(screen.getByText('View Comments')); + + // Should navigate to comments page, not stay on edit + await waitFor(() => { + expect( + screen.getByText(/Comments for Post/) + ).toBeInTheDocument(); + }); + }); + }); +}); diff --git a/packages/ra-router-react-router-next/src/reactRouterNextProvider.stories.tsx b/packages/ra-router-react-router-next/src/reactRouterNextProvider.stories.tsx new file mode 100644 index 00000000000..8d072c61625 --- /dev/null +++ b/packages/ra-router-react-router-next/src/reactRouterNextProvider.stories.tsx @@ -0,0 +1,1585 @@ +import * as React from 'react'; +import fakeDataProvider from 'ra-data-fakerest'; +import { + createHashRouter, + RouterProvider, + Outlet, + Link as ReactRouterLink, + useNavigate as useReactRouterNavigate, +} from 'react-router'; +import { + useNavigate, + useLocation, + LinkBase, + ListBase, + ShowBase, + EditBase, + CreateBase, + useRecordContext, + CoreAdmin, + Resource, + CustomRoutes, + RouterProviderContext, + testUI, +} from 'ra-core'; +import { reactRouterNextProvider } from './reactRouterNextProvider'; + +const { + useParams, + useMatch, + useInRouterContext, + useCanBlock, + Route, + Navigate, +} = reactRouterNextProvider; +const { TextInput, SimpleList, SimpleShowLayout, SimpleForm, CreateButton } = + testUI; + +export default { + title: 'ra-routing-react-router-next/React Router v8 Provider', +}; + +const dataProvider = fakeDataProvider( + { + posts: [ + { id: 1, title: 'Post #1', body: 'Hello World' }, + { id: 2, title: 'Post #2', body: 'Second post' }, + { id: 3, title: 'Post #3', body: 'Third post' }, + { id: 4, title: 'Post #4', body: 'Fourth post' }, + ], + comments: [ + { id: 1, post_id: 1, body: 'Nice post!' }, + { id: 2, post_id: 1, body: 'Great article' }, + ], + }, + process.env.NODE_ENV === 'development' +); + +const PostList = () => ( + +
    +

    Posts

    + ( + + {record.title} + + )} + /> + +
    +
    +); + +const PostShow = () => ( + ( +
    +

    Post Details

    + + ID: {record?.id} + Title: {record?.title} + Body: {record?.body} + + Back to list +
    + )} + /> +); + +const PostEdit = () => ( + +
    +

    Edit Post

    + + + + +
    +
    +); + +const PostCreate = () => ( + +
    +

    Create Post

    + + + + +
    +
    +); + +const LocationDisplay = () => { + const location = useLocation(); + return ( +
    + Current Location: +
    {JSON.stringify(location, null, 2)}
    +
    window.location.hash: {window.location.hash}
    +
    + ); +}; + +const LayoutWithLocationDisplay = ({ + children, +}: { + children: React.ReactNode; +}) => ( +
    + {children} + +
    +); + +/** + * Basic: Admin creates its own React Router v8 (standalone mode) + * Tests basic navigation, links, and programmatic navigation. + */ +export const Basic = () => ( + + + +); + +/** + * EmbeddedInReactRouter: Admin inside an existing React Router app + * Tests that react-admin detects existing router and uses it. + */ +// Nav component that uses the router for navigation +const EmbeddedNav = () => { + const navigate = useReactRouterNavigate(); + return ( + + ); +}; + +// Create routes outside the component to avoid recreating on every render +const embeddedRootRoute = { + element: ( +
    + + +
    + ), +}; + +const embeddedHomeRoute = { + index: true, + element: ( +
    +

    Home Page

    +

    This is a React Router app with embedded react-admin.

    + Go to Admin +
    + ), +}; + +const EmbeddedAdmin = () => ( + + + +); + +// Splat route to handle /admin, /admin/posts, /admin/posts/1/show, etc. +const embeddedAdminRoute = { path: 'admin/*', element: }; + +const embeddedRouteTree = [ + { + path: '/', + ...embeddedRootRoute, + children: [embeddedHomeRoute, embeddedAdminRoute], + }, +]; + +/** + * Admin inside an existing React Router app + * Tests that react-admin detects existing router and uses it. + */ +export const EmbeddedInReactRouter = () => { + const router = React.useMemo(() => createHashRouter(embeddedRouteTree), []); + + return ; +}; + +/** + * Tests back/forward navigation + */ +export const HistoryNavigation = () => { + const HistoryButtons = () => { + const navigate = useNavigate(); + return ( +
    + + +
    + ); + }; + + const ListWithHistory = () => ( +
    + + +
    + ); + + const ShowWithHistory = () => ( +
    + + +
    + ); + + return ( + + } + show={} + /> + + ); +}; + +/** + * Tests that routes match correctly + * Tests resource routes, custom routes, and catch-all routes. + */ +export const RouteMatching = () => { + const Dashboard = () => ( +
    +

    Dashboard

    +

    Welcome to the admin dashboard.

    +
      +
    • + Posts +
    • +
    +
    + ); + + return ( + + + + ); +}; + +/** + * Tests to, replace, state props work correctly. + */ +export const LinkComponent = () => { + const LinkTestPage = () => ( +
    +

    Link Component Tests

    + +

    Basic Link

    + Go to Post #1 + +

    Link with Replace

    + + Go to Post #2 (replace history) + + +

    Link with State

    + + Go to Post #3 (with state) + + +

    Link with Location object

    + + Go to Post #4 (with search) + + +

    Link with no pathname change

    + + Go to same page with search param + +
    + ); + + return ( + + + + ); +}; + +/** + * Tests navigation between multiple resources + */ +export const MultipleResources = () => { + const CommentList = () => ( +
    +

    Comments

    +
      +
    • Comment #1: Nice post!
    • +
    • Comment #2: Great article
    • +
    + Go to Posts +
    + ); + + return ( + + + + Go to Comments + + } + show={PostShow} + /> + + + ); +}; + +export const CustomRoutesSupport = () => { + const CustomPage = () => { + const navigate = useNavigate(); + return ( +
    +

    Custom Page

    +

    + This is a custom route using react-router's Route component. +

    + +
    + ); + }; + + const CustomNoLayoutPage = () => ( +
    +

    Custom Page (No Layout)

    +

    This page renders outside the layout.

    + Go to Posts + +
    + ); + + return ( + + + } /> + + + } + /> + + + +
    + Go to Custom Page +
    + + Go to Custom Page (No Layout) + +
    + + } + /> +
    + ); +}; + +/** + * Displays URL parameters extracted from the current route. + */ +export const UseParamsTest = () => { + const ParamsDisplay = () => { + const params = useParams(); + return ( +
    + URL Params: +
    +                    {JSON.stringify(params, null, 2)}
    +                
    +
    + ); + }; + + const PostShowWithParams = () => { + const record = useRecordContext(); + return ( +
    +

    Post Details

    + + {record && ( + <> +

    + ID: {record.id} +

    +

    + Title: {record.title} +

    + + )} + Back to List +
    + ); + }; + + return ( + + +

    Posts

    + +
      +
    • + Post #1 +
    • +
    • + Post #2 +
    • +
    + + } + show={} + /> +
    + ); +}; + +/** + * Shows active link highlighting based on current route match. + */ +export const UseMatchTest = () => { + const NavLink = ({ + to, + children, + }: { + to: string; + children: React.ReactNode; + }) => { + const match = useMatch({ path: to, end: false }); + return ( + + {children} + + ); + }; + + const MatchDisplay = () => { + const postsMatch = useMatch({ path: '/posts', end: false }); + const commentsMatch = useMatch({ path: '/comments', end: false }); + const exactPostsMatch = useMatch({ path: '/posts', end: true }); + + return ( +
    + Match Results: +
    + /posts (end: false): {postsMatch ? 'MATCH' : 'no match'} +
    +
    + /posts (end: true): {exactPostsMatch ? 'MATCH' : 'no match'} +
    +
    + /comments (end: false):{' '} + {commentsMatch ? 'MATCH' : 'no match'} +
    +
    + ); + }; + + const NavBar = () => ( + + ); + + return ( + + + + +
    +

    Posts List

    +
      +
    • + + Post #1 + +
    • +
    +
    + + } + show={ +
    + + +
    +

    Post Show

    + Back to List +
    +
    + } + /> + + + +
    +

    Comments List

    +
    + + } + /> +
    + ); +}; + +/** + * Blocks navigation when there are unsaved changes. + */ +export const UseWarnWhenUnsavedChangesTest = () => { + const FormWithWarnWhenUnsavedChanges = () => ( +
    +

    Form with Unsaved Changes Warning

    + + + + + Go to Comments +
    + ); + + return ( + + } /> + +

    Comments

    +

    You navigated away from the form.

    + Back to Form + + } + /> +
    + ); +}; + +export const NavigateComponent = () => { + const DummyPage = () => { + return ( +
    +

    Dummy page

    + +
    + ); + }; + + const RedirectPage = () => { + return ( +
    +

    Redirecting...

    + +
    + ); + }; + + const ConditionalRedirect = () => { + const [shouldRedirect, setShouldRedirect] = React.useState(false); + return ( +
    +

    Conditional Redirect

    + {shouldRedirect ? ( + + ) : ( +
    +

    Click the button to trigger a redirect.

    + +
    + )} +
    + ); + }; + + // Page that uses Navigate with only search params (no pathname) + // This should stay on the current page but update search params + const SearchOnlyRedirectPage = () => { + const location = useLocation(); + const hasUpdatedParam = location.search.includes('updated'); + + return ( +
    +

    Search-Only Redirect Page

    +

    + This page tests Navigate with only search params. +

    + {!hasUpdatedParam && ( + + + + )} + {hasUpdatedParam && ( +

    + Search params updated successfully! +

    + )} +
    + ); + }; + + // Page that demonstrates Navigate with only search (redirects once) + const NavigateSearchOnlyPage = () => { + const location = useLocation(); + const hasRedirected = location.search.includes('redirected'); + + // Only render Navigate if we haven't already redirected + // This prevents infinite navigation loops + if (!hasRedirected) { + return ( +
    +

    Redirecting with search only...

    + +
    + ); + } + + return ( +
    +

    Navigate Search-Only Test

    +

    + Successfully navigated with search-only (no pathname). +

    +
    + ); + }; + + return ( + + + } /> + } /> + } + /> + } + /> + } + /> + + +

    Posts

    +

    + You are on the posts page. +

    +
      +
    • + + Go to Redirect Page (auto-redirects here) + +
    • +
    • + + Go to Conditional Redirect + +
    • +
    • + + Go to redirect with params + +
    • +
    • + + Go to search-only redirect test (Link) + +
    • +
    • + + Go to Navigate search-only test + +
    • +
    + + } + /> +
    + ); +}; + +export const UseLocationTest = () => { + const DetailedLocationDisplay = () => { + const location = useLocation(); + return ( +
    +

    useLocation() Result:

    +
    + pathname: {location.pathname} +
    +
    + search: "{location.search}" +
    +
    + hash: "{location.hash}" +
    +
    + state:{' '} + {JSON.stringify(location.state) || 'null'} +
    +
    + ); + }; + + return ( + + +

    Location Test

    + +
    +

    Navigation Links:

    +
      +
    • + + Go to Post Show + +
    • +
    • + + Go to Post Show (with state) + +
    • +
    +
    + + } + show={ +
    +

    Post Show

    + + Back to List +
    + } + /> +
    + ); +}; + +/** + * RouterContextTest: Tests useInRouterContext and useCanBlock hooks + */ +export const RouterContextTest = () => { + const ContextInfo = () => { + const isInRouter = useInRouterContext(); + const canBlock = useCanBlock(); + + return ( +
    +

    Router Context Info:

    +
    + useInRouterContext():{' '} + {isInRouter ? 'true' : 'false'} +
    +
    + useCanBlock():{' '} + {canBlock ? 'true' : 'false'} +
    +
    + ); + }; + + return ( + + +

    Router Context Test

    + + + } + /> +
    + ); +}; + +const { Routes, Outlet: RouterOutlet } = reactRouterNextProvider; + +export const NestedResources = () => ( + + }> + } /> + + +); + +const PostEditWithLinkToComments = () => { + const navigate = useNavigate(); + return ( + ( +
    +

    Post Details

    + {record &&

    {record.title}

    } + + +
    + )} + /> + ); +}; + +const CommentList = () => { + const { post_id } = useParams(); + const navigate = useNavigate(); + return ( + ( +
    +

    Comments for Post {post_id}

    +
      + {data?.map(record => ( +
    • {record.body}
    • + ))} +
    + +
    + )} + /> + ); +}; + +export const NestedResourcesPrecedence = () => ( + + + } /> + + +); + +/** + * Tests that query parameters work correctly (for list sorting, filtering, pagination). + * This tests the navigate({ search: '?...' }) pattern used by useListParams. + */ +export const QueryParameters = () => { + const ListWithQueryParams = () => { + const location = useLocation(); + const navigate = useNavigate(); + + // Parse current query params + const searchParams = new URLSearchParams(location.search); + const sort = searchParams.get('sort') || 'id'; + const order = searchParams.get('order') || 'ASC'; + const page = searchParams.get('page') || '1'; + + const setSort = (field: string, newOrder: string) => { + navigate({ + search: `?sort=${field}&order=${newOrder}&page=${page}`, + }); + }; + + const setPage = (newPage: number) => { + navigate({ + search: `?sort=${sort}&order=${order}&page=${newPage}`, + }); + }; + + return ( +
    +

    Posts with Query Parameters

    +
    +
    + Current search: {location.search || '(empty)'} +
    +
    + Sort: {sort} {order} +
    +
    Page: {page}
    +
    +
    + Sort by:{' '} + {' '} + +
    +
    + Page:{' '} + {' '} + {' '} + +
    +
      +
    • Post #1
    • +
    • Post #2
    • +
    • Post #3
    • +
    +
    + ); + }; + + return ( + + } /> + + ); +}; + +/** + * This tests the pattern where a parent Route has child Routes and uses Outlet + * to render the matched child (like TabbedShowLayout). + */ +export const NestedRoutesWithOutlet = () => { + const TabbedLayout = () => { + const location = useLocation(); + return ( +
    +

    Tabbed Layout (like TabbedShowLayout)

    + + + +
    + +
    +
    + } + > + +

    Content Tab

    +

    + This is the content tab (first tab, + default). +

    +

    Title: Hello World

    +

    Body: Welcome to react-admin!

    + + } + /> + +

    Metadata Tab

    +

    + This is the metadata tab (second tab). +

    +

    ID: 1

    +

    Created: 2024-01-15

    +

    Author: Admin

    + + } + /> +
    + + + ); + }; + + return ( + + + + ); +}; + +export const PathlessLayoutRoutes = () => { + const { RouterWrapper } = reactRouterNextProvider; + + return ( + + + + +

    Layout Wrapper

    + +
    + +
    + + } + > + Posts Page + } + /> + + Comments Page + + } + /> + +
    + +
    +
    + ); +}; + +export const PathlessLayoutRoutesPriority = () => { + const { RouterWrapper } = reactRouterNextProvider; + + return ( + + +
    + +
    + + + Posts Page +
    + } + /> + + Comments Page +
    + } + /> + + + + } + > + + Users View + + } + /> + + + + + } + > + + Block a user + + } + /> + + + + + +
    +
    + ); +}; + +export const PathlessLayoutRoutesWithEmptyRoute = () => { + const { RouterWrapper } = reactRouterNextProvider; + + return ( + + +

    + Expected: "/" renders Home Page (path=""). If you see + Catch-all Page instead, path="" is being treated as + catch-all. +

    + + + Catch-all Page + + } + /> + +

    Layout Wrapper

    + + +
    + +
    + + } + > + + Home Page (path="") + + } + /> + Posts Page + } + /> + + Comments Page + + } + /> + +
    + +
    +
    + ); +}; + +export const PathlessLayoutRoutesWithIndexRoute = () => { + const { RouterWrapper } = reactRouterNextProvider; + + return ( + + + + + Catch-all Page + + } + /> + +

    Layout Wrapper

    + + +
    + +
    + + } + > + + Home Page (index) + + } + /> + Posts Page + } + /> + + Comments Page + + } + /> + +
    + +
    +
    + ); +}; diff --git a/packages/ra-router-react-router-next/src/reactRouterNextProvider.tsx b/packages/ra-router-react-router-next/src/reactRouterNextProvider.tsx new file mode 100644 index 00000000000..67e6851c770 --- /dev/null +++ b/packages/ra-router-react-router-next/src/reactRouterNextProvider.tsx @@ -0,0 +1,234 @@ +import * as React from 'react'; +import { useContext, useEffect, useRef, forwardRef, ReactNode } from 'react'; +import { + useNavigate as useReactRouterNavigate, + useLocation, + useParams, + useBlocker, + useMatch, + useInRouterContext, + Link as ReactRouterLink, + Navigate as ReactRouterNavigate, + Route, + Routes, + Outlet, + matchPath, + createHashRouter, + RouterProvider as ReactRouterProvider, + UNSAFE_DataRouterContext, + UNSAFE_DataRouterStateContext, +} from 'react-router'; +import { useBasename } from 'ra-core'; +import type { + RouterProvider, + RouterWrapperProps, + RouterNavigateFunction, + RouterLinkProps, + RouterNavigateProps, +} from 'ra-core'; + +/** + * Hook to check if navigation blocking is supported. + * In react-router, blocking requires a data router. + */ +const useCanBlock = (): boolean => { + const dataRouterContext = useContext(UNSAFE_DataRouterContext); + const dataRouterStateContext = useContext(UNSAFE_DataRouterStateContext); + return !!(dataRouterContext && dataRouterStateContext); +}; + +/** + * Wrapper around react-router's useNavigate that returns a stable function reference. + * + * react-router's useNavigate forces rerenders on every navigation, even if we don't use the result. + * @see https://github.com/remix-run/react-router/issues/7634 + * + * This wrapper uses a ref to return a stable function reference, avoiding unnecessary rerenders + * in components that use navigate but don't need to rerender on navigation. + */ +const useNavigate = (): RouterNavigateFunction => { + const navigate = useReactRouterNavigate(); + const basename = useBasename(); + const navigateRef = useRef( + navigate as RouterNavigateFunction + ); + + useEffect(() => { + navigateRef.current = navigate as RouterNavigateFunction; + }, [navigate]); + + // Return a stable function that always calls the latest navigate + return React.useCallback( + (...args: Parameters) => { + const [to, ...rest] = args; + + // Handle numeric navigation (go back/forward) + if (typeof to === 'number') { + return navigateRef.current(to, ...rest); + } + + // Helper to prepend basename to absolute paths + // Only prepend if path doesn't already start with basename + const resolvePath = (path: string) => { + if (!basename || !path.startsWith('/')) return path; + // Don't prepend if path already includes basename + if (path.startsWith(basename + '/') || path === basename) { + return path; + } + return `${basename}${path}`; + }; + + // Handle object navigation { pathname?, search?, hash?, state? } + // This covers both { pathname: '/foo' } and { search: '?bar=1' } + if (typeof to === 'object' && to !== null) { + // If no pathname provided, keep current pathname + return navigateRef.current( + to.pathname + ? { ...to, pathname: resolvePath(to.pathname) } + : to, + ...rest + ); + } + + // Handle string path + const resolvedPath = resolvePath(to as string); + return navigateRef.current(resolvedPath, ...rest); + }, + [basename] + ) as RouterNavigateFunction; +}; + +const Link = forwardRef( + ({ to, ...rest }, ref) => { + const basename = useBasename(); + + // Helper to prepend basename to absolute paths + const resolvePath = (path: string) => { + if (!basename || !path.startsWith('/')) return path; + if (path.startsWith(basename + '/') || path === basename) { + return path; + } + return `${basename}${path}`; + }; + + // Handle object `to` (e.g., { pathname: '/path', search: '?foo=bar' }) + let resolvedTo: typeof to; + if (typeof to === 'object' && to !== null) { + // If no pathname provided, keep it as-is to stay on current page + resolvedTo = to.pathname + ? { ...to, pathname: resolvePath(to.pathname) } + : to; + } else { + resolvedTo = resolvePath(to as string); + } + return ; + } +); +Link.displayName = 'Link'; + +const Navigate = ({ to, ...rest }: RouterNavigateProps) => { + const basename = useBasename(); + const currentLocation = useLocation(); + + // Handle both string and object forms of `to` + let resolvedPath: string; + + if (typeof to === 'string') { + resolvedPath = to; + } else { + // If no pathname provided, use current pathname to stay on current page + resolvedPath = to.pathname ?? currentLocation.pathname; + + // Append search and hash directly to the path to preserve the raw + // query string format + if (to.search) { + resolvedPath += to.search.startsWith('?') + ? to.search + : `?${to.search}`; + } + if (to.hash) { + resolvedPath += to.hash.startsWith('#') ? to.hash : `#${to.hash}`; + } + } + + // Prepend basename to the path + // Only prepend if path doesn't already start with basename + if (basename && resolvedPath.startsWith('/')) { + if ( + !resolvedPath.startsWith(basename + '/') && + resolvedPath !== basename + ) { + resolvedPath = `${basename}${resolvedPath}`; + } + } + + return ; +}; + +/** + * Internal router component that creates a HashRouter. + * Only used when not already inside a router context. + */ +const InternalRouter = ({ + children, + basename, +}: { + children: ReactNode; + basename?: string; +}) => { + const router = createHashRouter([{ path: '*', element: <>{children} }], { + basename, + }); + return ; +}; + +/** + * RouterWrapper component for react-router. + * Creates a HashRouter if not already inside a router context. + */ +const RouterWrapper = ({ basename, children }: RouterWrapperProps) => { + const isInRouter = useInRouterContext(); + + if (isInRouter) { + return <>{children}; + } + + return {children}; +}; + +/** + * Router provider for react-router v8. + * + * react-router v8 merged the former `react-router-dom` package into `react-router` + * and dropped the v6/v7 `future` flags (they are now the default behavior), so this + * adapter is a thin pass-through over the native `react-router` API. + * + * react-admin uses its built-in react-router v6/v7 adapter by default. Pass this + * provider to `` to run on react-router v8. + * New projects are encouraged to use this provider; it will become the default + * (republished as `ra-router-react-router`) in react-admin v6. + */ +// FIXME kept for BC: republish as ra-router-react-router for react-admin v6 +export const reactRouterNextProvider: RouterProvider = { + // Hooks + useNavigate, + useLocation, + useParams: useParams as RouterProvider['useParams'], + useBlocker, + useMatch, + useInRouterContext, + useCanBlock, + + // Components + Link, + Navigate, + Route, + Routes, + Outlet, + + // Router creation + RouterWrapper, + + // Utilities + matchPath, +}; diff --git a/packages/ra-router-react-router-next/tsconfig.json b/packages/ra-router-react-router-next/tsconfig.json new file mode 100644 index 00000000000..15b7d56fb2d --- /dev/null +++ b/packages/ra-router-react-router-next/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "src", + "allowJs": false, + "strictNullChecks": true, + }, + "exclude": ["**/*.spec.ts", "**/*.spec.tsx"], + "include": ["src"] +} diff --git a/packages/ra-router-react-router/README.md b/packages/ra-router-react-router/README.md new file mode 100644 index 00000000000..56c310c34ae --- /dev/null +++ b/packages/ra-router-react-router/README.md @@ -0,0 +1,49 @@ +# ra-router-react-router + +[React Router v6/v7](https://reactrouter.com) adapter for [react-admin](https://github.com/marmelab/react-admin). + +This is the **default** router adapter used by react-admin. `ra-core` and +`react-admin` depend on it, so you don't need to install or configure it +yourself — react-admin works on React Router v6/v7 out of the box. + +> **Note:** This package requires `ra-core` (it implements `ra-core`'s +> `RouterProvider` interface). It is installed automatically as a dependency of +> `ra-core` and `react-admin`. + +## Usage + +The provider is wired up automatically. You only need to reference it directly +in advanced scenarios, for example to pass it explicitly to ``: + +```tsx +import { Admin, Resource } from 'react-admin'; +import { reactRouterProvider } from 'ra-router-react-router'; +import { dataProvider } from './dataProvider'; +import { PostList, PostEdit, PostCreate } from './posts'; + +export const App = () => ( + + + +); +``` + +By default the provider creates a hash router. When react-admin is rendered +inside an existing React Router context, it uses that router instead. + +## Running on React Router v8 + +New projects are encouraged to run react-admin on React Router v8 by using the +[`ra-router-react-router-next`](../ra-router-react-router-next) adapter. The v6/v7 +adapter shipped in this package remains the default for backward compatibility, +and `ra-router-react-router-next` will become `ra-router-react-router` in +react-admin v6. + +## License + +MIT diff --git a/packages/ra-router-react-router/package.json b/packages/ra-router-react-router/package.json new file mode 100644 index 00000000000..7a63c2e3a89 --- /dev/null +++ b/packages/ra-router-react-router/package.json @@ -0,0 +1,52 @@ +{ + "name": "ra-router-react-router", + "version": "5.14.7", + "description": "React Router v6/v7 provider for react-admin (the default router adapter)", + "files": [ + "*.md", + "dist", + "src" + ], + "zshy": "./src/index.ts", + "main": "./dist/index.cjs", + "module": "./dist/index.js", + "types": "./dist/index.d.cts", + "type": "module", + "sideEffects": false, + "repository": "marmelab/react-admin", + "homepage": "https://github.com/marmelab/react-admin#readme", + "bugs": "https://github.com/marmelab/react-admin/issues", + "author": "François Zaninotto", + "license": "MIT", + "scripts": { + "build": "zshy --silent" + }, + "devDependencies": { + "react-router": "^6.28.1", + "react-router-dom": "^6.28.1", + "typescript": "^5.1.3", + "zshy": "^0.5.0" + }, + "peerDependencies": { + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0", + "react-router": "^6.28.1 || ^7.1.1", + "react-router-dom": "^6.28.1 || ^7.1.1" + }, + "dependencies": { + "react-router": "^6.28.1 || ^7.1.1", + "react-router-dom": "^6.28.1 || ^7.1.1" + }, + "exports": { + ".": { + "import": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + }, + "require": { + "types": "./dist/index.d.cts", + "default": "./dist/index.cjs" + } + } + } +} diff --git a/packages/ra-router-react-router/src/index.ts b/packages/ra-router-react-router/src/index.ts new file mode 100644 index 00000000000..4055763ab42 --- /dev/null +++ b/packages/ra-router-react-router/src/index.ts @@ -0,0 +1 @@ +export * from './reactRouterProvider'; diff --git a/packages/ra-router-react-router/src/reactRouterProvider.spec.tsx b/packages/ra-router-react-router/src/reactRouterProvider.spec.tsx new file mode 100644 index 00000000000..c7f059ab64c --- /dev/null +++ b/packages/ra-router-react-router/src/reactRouterProvider.spec.tsx @@ -0,0 +1,1533 @@ +import * as React from 'react'; +import { + render, + screen, + fireEvent, + waitFor, + cleanup, +} from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { + Basic, + EmbeddedInReactRouter, + HistoryNavigation, + LinkComponent, + MultipleResources, + CustomRoutesSupport, + UseParamsTest, + UseMatchTest, + UseWarnWhenUnsavedChangesTest, + NavigateComponent, + UseLocationTest, + RouterContextTest, + NestedRoutesWithOutlet, + NestedResources, + QueryParameters, + PathlessLayoutRoutes, + NestedResourcesPrecedence, + PathlessLayoutRoutesPriority, + PathlessLayoutRoutesWithEmptyRoute, + PathlessLayoutRoutesWithIndexRoute, +} from './reactRouterProvider.stories'; +import { reactRouterProvider } from './reactRouterProvider'; + +const { matchPath } = reactRouterProvider; + +describe('reactRouterProvider', () => { + // Reset hash before each test to ensure clean state + beforeEach(() => { + window.location.hash = ''; + }); + + afterEach(() => { + cleanup(); + window.location.hash = ''; + }); + + describe('matchPath', () => { + describe('catch-all patterns', () => { + it('should match "*" against any path', () => { + expect(matchPath('*', '/anything')).toMatchObject({ + params: { '*': 'anything' }, + pathname: '/anything', + pathnameBase: '/', + }); + }); + + it('should match "*" against root path', () => { + expect(matchPath('*', '/')).toMatchObject({ + params: { '*': '' }, + pathname: '/', + pathnameBase: '/', + }); + }); + + it('should match "/*" against root path', () => { + expect(matchPath('/*', '/')).toMatchObject({ + params: { '*': '' }, + pathname: '/', + pathnameBase: '/', + }); + }); + + it('should match "/*" against nested path', () => { + expect(matchPath('/*', '/posts/1/show')).toMatchObject({ + params: { '*': 'posts/1/show' }, + pathname: '/posts/1/show', + pathnameBase: '/', + }); + }); + }); + + describe('root/empty paths', () => { + it('should match "/" against "/"', () => { + expect(matchPath('/', '/')).toMatchObject({ + params: {}, + pathname: '/', + pathnameBase: '/', + }); + }); + + it('should match "" against "/"', () => { + expect(matchPath('', '/')).toMatchObject({ + params: {}, + pathname: '/', + pathnameBase: '/', + }); + }); + + it('should not match "" against ""', () => { + expect(matchPath('', '')).toBeNull(); + }); + + it('should not match "/" against "/posts" by default (end=true)', () => { + expect(matchPath('/', '/posts')).toBeNull(); + }); + + it('should match "/" against "/posts" with end=false', () => { + expect( + matchPath({ path: '/', end: false }, '/posts') + ).toMatchObject({ + params: {}, + pathname: '/', + pathnameBase: '/', + }); + }); + }); + + describe('static paths', () => { + it('should match exact static path', () => { + expect(matchPath('/posts', '/posts')).toMatchObject({ + params: {}, + pathname: '/posts', + pathnameBase: '/posts', + }); + }); + + it('should match static path with trailing slash in pathname', () => { + expect(matchPath('/posts', '/posts/')).toMatchObject({ + params: {}, + pathname: '/posts/', + pathnameBase: '/posts', + }); + }); + + it('should not match static path against longer path by default', () => { + expect(matchPath('/posts', '/posts/1')).toBeNull(); + }); + + it('should match static path as prefix with end=false', () => { + expect( + matchPath({ path: '/posts', end: false }, '/posts/1') + ).toMatchObject({ + params: {}, + pathname: '/posts', + pathnameBase: '/posts', + }); + }); + + it('should match nested static path', () => { + expect( + matchPath('/users/settings', '/users/settings') + ).toMatchObject({ + params: {}, + pathname: '/users/settings', + pathnameBase: '/users/settings', + }); + }); + + it('should not match different static path', () => { + expect(matchPath('/posts', '/comments')).toBeNull(); + }); + }); + + describe('dynamic params', () => { + it('should match single param', () => { + expect(matchPath('/posts/:id', '/posts/123')).toMatchObject({ + params: { id: '123' }, + pathname: '/posts/123', + pathnameBase: '/posts/123', + }); + }); + + it('should match multiple params', () => { + expect( + matchPath( + '/users/:userId/posts/:postId', + '/users/1/posts/2' + ) + ).toMatchObject({ + params: { userId: '1', postId: '2' }, + pathname: '/users/1/posts/2', + pathnameBase: '/users/1/posts/2', + }); + }); + + it('should match param with special characters in value', () => { + expect( + matchPath('/posts/:id', '/posts/hello-world') + ).toMatchObject({ + params: { id: 'hello-world' }, + pathname: '/posts/hello-world', + pathnameBase: '/posts/hello-world', + }); + }); + + it('should not match param when segment is missing', () => { + expect(matchPath('/posts/:id', '/posts')).toBeNull(); + expect(matchPath('/posts/:id', '/posts/')).toBeNull(); + }); + + it('should match param at root level', () => { + expect(matchPath('/:resource', '/posts')).toMatchObject({ + params: { resource: 'posts' }, + pathname: '/posts', + pathnameBase: '/posts', + }); + }); + + it('should decode only path separator in URL-encoded params', () => { + // UTF-8 characters: 衣類/衣類 encoded + expect( + matchPath( + '/comments/:id', + '/comments/%E8%A1%A3%E9%A1%9E%2F%E8%A1%A3%E9%A1%9E' + ) + ).toMatchObject({ + params: { id: '%E8%A1%A3%E9%A1%9E/%E8%A1%A3%E9%A1%9E' }, + pathname: + '/comments/%E8%A1%A3%E9%A1%9E%2F%E8%A1%A3%E9%A1%9E', + }); + }); + + it('should keep percent-encoded spaces in params', () => { + expect( + matchPath('/posts/:id', '/posts/hello%20world') + ).toMatchObject({ + params: { id: 'hello%20world' }, + pathname: '/posts/hello%20world', + pathnameBase: '/posts/hello%20world', + }); + }); + }); + + describe('splat patterns (path/*)', () => { + it('should match splat with content', () => { + expect(matchPath('/posts/*', '/posts/1/show')).toMatchObject({ + params: { '*': '1/show' }, + pathname: '/posts/1/show', + pathnameBase: '/posts', + }); + }); + + it('should match splat at root of pattern', () => { + expect(matchPath('/posts/*', '/posts')).toMatchObject({ + params: { '*': '' }, + pathname: '/posts', + pathnameBase: '/posts', + }); + }); + + it('should match splat with trailing slash', () => { + expect(matchPath('/posts/*', '/posts/')).toMatchObject({ + params: { '*': '' }, + pathname: '/posts/', + pathnameBase: '/posts', + }); + }); + + it('should match splat with deeply nested path', () => { + expect( + matchPath('/admin/*', '/admin/users/1/edit') + ).toMatchObject({ + params: { '*': 'users/1/edit' }, + pathname: '/admin/users/1/edit', + pathnameBase: '/admin', + }); + }); + + it('should decode only path separator in URL-encoded splat values', () => { + expect( + matchPath('/files/*', '/files/path%2Fto%2Ffile%20name.txt') + ).toMatchObject({ + params: { '*': 'path/to/file%20name.txt' }, + pathname: '/files/path%2Fto%2Ffile%20name.txt', + pathnameBase: '/files', + }); + }); + }); + + describe('combined params and splat', () => { + it('should match param followed by splat', () => { + expect( + matchPath('/:resource/*', '/posts/1/show') + ).toMatchObject({ + params: { resource: 'posts', '*': '1/show' }, + pathname: '/posts/1/show', + pathnameBase: '/posts', + }); + }); + + it('should match multiple params with splat', () => { + expect( + matchPath('/:resource/:id/*', '/posts/1/comments/2') + ).toMatchObject({ + params: { resource: 'posts', id: '1', '*': 'comments/2' }, + pathname: '/posts/1/comments/2', + pathnameBase: '/posts/1', + }); + }); + + it('should match param and empty splat', () => { + expect(matchPath('/:resource/*', '/posts')).toMatchObject({ + params: { resource: 'posts', '*': '' }, + pathname: '/posts', + pathnameBase: '/posts', + }); + }); + }); + + describe('ReDoS avoidance and edge cases', () => { + it('should handle long paths efficiently', () => { + const longPath = + '/a/b/c/d/e/f/g/h/i/j/k/l/m/n/o/p/q/r/s/t/u/v/w/x/y/z'; + const pattern = + '/a/b/c/d/e/f/g/h/i/j/k/l/m/n/o/p/q/r/s/t/u/v/w/x/y/z'; + expect(matchPath(pattern, longPath)).not.toBeNull(); + }); + + it('should handle long paths with mismatch at the end efficiently', () => { + const longPath = + '/a/b/c/d/e/f/g/h/i/j/k/l/m/n/o/p/q/r/s/t/u/v/w/x/y/z/mismatch'; + const pattern = + '/a/b/c/d/e/f/g/h/i/j/k/l/m/n/o/p/q/r/s/t/u/v/w/x/y/z/match'; + expect(matchPath(pattern, longPath)).toBeNull(); + }); + + it('should not match a path with collapsed multiple slashes', () => { + expect(matchPath('/a/b', '///a///b///')).toBeNull(); + }); + + it('should handle special characters in path segments', () => { + expect( + matchPath('/files/:filename', '/files/image.png') + ).toMatchObject({ + params: { filename: 'image.png' }, + pathname: '/files/image.png', + pathnameBase: '/files/image.png', + }); + + expect( + matchPath('/search/:query', '/search/foo+bar%20baz') + ).toMatchObject({ + params: { query: 'foo+bar%20baz' }, + pathname: '/search/foo+bar%20baz', + pathnameBase: '/search/foo+bar%20baz', + }); + }); + }); + + describe('end option', () => { + it('should match exact path when end=true (default)', () => { + expect(matchPath('/posts', '/posts')).not.toBeNull(); + expect(matchPath('/posts', '/posts/1')).toBeNull(); + }); + + it('should match prefix when end=false', () => { + expect( + matchPath({ path: '/posts', end: false }, '/posts/1/show') + ).toMatchObject({ + params: {}, + pathname: '/posts', + pathnameBase: '/posts', + }); + }); + + it('should match param prefix when end=false', () => { + expect( + matchPath( + { path: '/posts/:id', end: false }, + '/posts/1/comments' + ) + ).toMatchObject({ + params: { id: '1' }, + pathname: '/posts/1', + pathnameBase: '/posts/1', + }); + }); + + it('should use end=true when pattern is string', () => { + expect(matchPath('/posts', '/posts/1')).toBeNull(); + }); + + it('should use end=true when end is not specified in object', () => { + expect(matchPath({ path: '/posts' }, '/posts/1')).toBeNull(); + }); + }); + + describe('paths without leading slash', () => { + it('should normalize path without leading slash', () => { + expect(matchPath('posts', '/posts')).toMatchObject({ + params: {}, + pathname: '/posts', + pathnameBase: '/posts', + }); + }); + + it('should normalize param path without leading slash', () => { + expect(matchPath('posts/:id', '/posts/1')).toMatchObject({ + params: { id: '1' }, + pathname: '/posts/1', + pathnameBase: '/posts/1', + }); + }); + }); + + describe('trailing slashes', () => { + it('should match path with trailing slash in pattern', () => { + expect(matchPath('/posts/', '/posts')).toMatchObject({ + params: {}, + pathname: '/posts', + pathnameBase: '/posts', + }); + }); + + it('should match path with trailing slash in pathname', () => { + expect(matchPath('/posts', '/posts/')).toMatchObject({ + params: {}, + pathname: '/posts/', + pathnameBase: '/posts', + }); + }); + + it('should match when both have trailing slash', () => { + expect(matchPath('/posts/', '/posts/')).toMatchObject({ + params: {}, + pathname: '/posts/', + pathnameBase: '/posts', + }); + }); + }); + + describe('special regex characters in path', () => { + it('should escape dots in path', () => { + expect(matchPath('/api/v1.0', '/api/v1.0')).toMatchObject({ + params: {}, + pathname: '/api/v1.0', + pathnameBase: '/api/v1.0', + }); + }); + + it('should not match dot as wildcard', () => { + expect(matchPath('/api/v1.0', '/api/v1X0')).toBeNull(); + }); + }); + + describe('react-admin resource patterns', () => { + it('should match resource list pattern', () => { + expect(matchPath('/:resource/*', '/posts')).toMatchObject({ + params: { resource: 'posts', '*': '' }, + pathname: '/posts', + pathnameBase: '/posts', + }); + }); + + it('should match resource show pattern', () => { + expect( + matchPath('/:resource/:id/show', '/posts/1/show') + ).toMatchObject({ + params: { resource: 'posts', id: '1' }, + pathname: '/posts/1/show', + pathnameBase: '/posts/1/show', + }); + }); + + it('should match resource edit pattern', () => { + expect(matchPath('/:resource/:id', '/posts/1')).toMatchObject({ + params: { resource: 'posts', id: '1' }, + pathname: '/posts/1', + pathnameBase: '/posts/1', + }); + }); + + it('should match resource create pattern', () => { + expect( + matchPath('/:resource/create', '/posts/create') + ).toMatchObject({ + params: { resource: 'posts' }, + pathname: '/posts/create', + pathnameBase: '/posts/create', + }); + }); + }); + + describe('basename scenarios', () => { + it('should match path after basename is stripped', () => { + // When basename is /admin, the pathname passed to matchPath + // should already have basename stripped (this is done by Routes) + expect(matchPath('/posts', '/posts')).toMatchObject({ + params: {}, + pathname: '/posts', + pathnameBase: '/posts', + }); + }); + + it('should match root after basename is stripped', () => { + // After stripping /admin from /admin, we get / + expect(matchPath('/', '/')).toMatchObject({ + params: {}, + pathname: '/', + pathnameBase: '/', + }); + }); + + it('should match catch-all after basename is stripped', () => { + // /admin/posts/1 with basename /admin becomes /posts/1 + expect(matchPath('/*', '/posts/1')).toMatchObject({ + params: { '*': 'posts/1' }, + pathname: '/posts/1', + pathnameBase: '/', + }); + }); + + it('should match nested resource after basename is stripped', () => { + // /admin/posts/1/show with basename /admin becomes /posts/1/show + expect( + matchPath('/:resource/:id/show', '/posts/1/show') + ).toMatchObject({ + params: { resource: 'posts', id: '1' }, + pathname: '/posts/1/show', + pathnameBase: '/posts/1/show', + }); + }); + }); + }); + + describe('RouterWrapper', () => { + describe('standalone mode', () => { + it('should render the post list', async () => { + render(); + await waitFor(() => { + expect(screen.getByText('Posts')).toBeInTheDocument(); + }); + }); + + it('should display the current location', async () => { + render(); + await waitFor(() => { + expect( + screen.getByText('Current Location:') + ).toBeInTheDocument(); + }); + }); + }); + + describe('embedded mode', () => { + it('should render home page initially', async () => { + render(); + await waitFor(() => { + expect(screen.getByText('Home Page')).toBeInTheDocument(); + expect( + screen.getByText( + 'This is a React Router app with embedded react-admin.' + ) + ).toBeInTheDocument(); + }); + }); + + it('should navigate to admin section', async () => { + render(); + await waitFor(() => { + expect(screen.getByText('Admin')).toBeInTheDocument(); + }); + + fireEvent.click(screen.getByText('Admin')); + + await waitFor( + () => { + expect(screen.getByText('Posts')).toBeInTheDocument(); + }, + { timeout: 3000 } + ); + }); + + it('should navigate back to parent app', async () => { + const user = userEvent.setup(); + render(); + await waitFor(() => { + expect(screen.getByText('Admin')).toBeInTheDocument(); + }); + + await user.click(screen.getByText('Admin')); + + await waitFor( + () => { + expect(screen.getByText('Posts')).toBeInTheDocument(); + }, + { timeout: 3000 } + ); + + // Navigate back to home via hash change + window.location.hash = '#/'; + window.dispatchEvent(new HashChangeEvent('hashchange')); + + await waitFor( + () => { + expect( + screen.getByText('Home Page') + ).toBeInTheDocument(); + }, + { timeout: 3000 } + ); + }); + }); + }); + + describe('useNavigate', () => { + it('should navigate to a path programmatically', async () => { + render(); + await waitFor(() => { + expect(screen.getByText('Create')).toBeInTheDocument(); + }); + + fireEvent.click(screen.getByText('Create')); + + await waitFor(() => { + expect(screen.getByText('Create Post')).toBeInTheDocument(); + }); + }); + + it('should navigate back in history with navigate(-1)', async () => { + render(); + + await screen.findByText('Post #1'); + + fireEvent.click(screen.getByText('Post #1')); + + await waitFor(() => { + expect(screen.getByText('Post Details')).toBeInTheDocument(); + }); + + fireEvent.click(screen.getByText('← Back')); + + await waitFor(() => { + expect(screen.getByText('Posts')).toBeInTheDocument(); + }); + }); + + it('should navigate within nested routes', async () => { + render(); + await waitFor(() => { + expect(screen.getByText('Admin')).toBeInTheDocument(); + }); + + fireEvent.click(screen.getByText('Admin')); + + await screen.findByText('Posts'); + + // Wait for data to load before clicking + await screen.findByText('Post #1'); + + fireEvent.click(screen.getByText('Post #1')); + + await waitFor( + () => { + expect( + screen.getByText('Post Details') + ).toBeInTheDocument(); + }, + { timeout: 3000 } + ); + }); + }); + + describe('Link', () => { + it('should render as an anchor element', async () => { + render(); + await waitFor(() => { + expect(screen.getByText('Post #1')).toBeInTheDocument(); + }); + expect(screen.getByText('Post #1').tagName).toBe('A'); + }); + + it('should navigate when clicked', async () => { + render(); + await waitFor(() => { + expect(screen.getByText('Post #1')).toBeInTheDocument(); + }); + + fireEvent.click(screen.getByText('Post #1')); + + await waitFor( + () => { + expect( + screen.getByText('Post Details') + ).toBeInTheDocument(); + }, + { timeout: 3000 } + ); + }); + + it('should support replace prop', async () => { + render(); + await waitFor(() => { + expect( + screen.getByText('Go to Post #2 (replace history)') + ).toBeInTheDocument(); + }); + }); + + it('should support state prop', async () => { + render(); + await waitFor(() => { + expect( + screen.getByText('Go to Post #3 (with state)') + ).toBeInTheDocument(); + }); + }); + + it('should support location object with pathname and search', async () => { + render(); + await waitFor(() => { + expect( + screen.getByText('Go to Post #4 (with search)') + ).toBeInTheDocument(); + }); + + fireEvent.click(screen.getByText('Go to Post #4 (with search)')); + + await waitFor(() => { + expect(screen.getByText('Post Details')).toBeInTheDocument(); + }); + // Check that search params are preserved in location + expect( + screen.getByText(/"search": "\?foo=bar"/) + ).toBeInTheDocument(); + }); + + it('should support location object with only search (no pathname)', async () => { + render(); + await waitFor(() => { + expect( + screen.getByText('Go to same page with search param') + ).toBeInTheDocument(); + }); + + fireEvent.click( + screen.getByText('Go to same page with search param') + ); + + await waitFor(() => { + // Should stay on the same page (Link Tests page) + expect( + screen.getByText('Link Component Tests') + ).toBeInTheDocument(); + }); + // Check that search params are added + expect( + screen.getByText(/"search": "\?foo=bar"/) + ).toBeInTheDocument(); + }); + }); + + describe('Routes', () => { + describe('resource routes', () => { + it('should match list routes', async () => { + render(); + await waitFor(() => { + expect(screen.getByText('Posts')).toBeInTheDocument(); + }); + }); + + it('should match show routes', async () => { + render(); + await waitFor(() => { + expect(screen.getByText('Post #1')).toBeInTheDocument(); + }); + + fireEvent.click(screen.getByText('Post #1')); + + await waitFor( + () => { + expect( + screen.getByText('Post Details') + ).toBeInTheDocument(); + }, + { timeout: 3000 } + ); + }); + + it('should navigate between resources', async () => { + render(); + await waitFor(() => { + expect(screen.getByText('Posts')).toBeInTheDocument(); + }); + + fireEvent.click(screen.getByText('Go to Comments')); + + await waitFor(() => { + expect(screen.getByText('Comments')).toBeInTheDocument(); + }); + + fireEvent.click(screen.getByText('Go to Posts')); + + await waitFor(() => { + expect(screen.getByText('Posts')).toBeInTheDocument(); + }); + }); + }); + + describe('custom routes', () => { + it('should render custom routes with layout', async () => { + render(); + await waitFor(() => { + expect( + screen.getByText('Go to Custom Page') + ).toBeInTheDocument(); + }); + + fireEvent.click(screen.getByText('Go to Custom Page')); + + await waitFor(() => { + expect(screen.getByText('Custom Page')).toBeInTheDocument(); + expect( + screen.getByText( + "This is a custom route using react-router's Route component." + ) + ).toBeInTheDocument(); + }); + }); + + it('should render custom routes without layout', async () => { + render(); + await waitFor(() => { + expect( + screen.getByText('Go to Custom Page (No Layout)') + ).toBeInTheDocument(); + }); + + fireEvent.click( + screen.getByText('Go to Custom Page (No Layout)') + ); + + await waitFor(() => { + expect( + screen.getByText('Custom Page (No Layout)') + ).toBeInTheDocument(); + expect( + screen.getByText( + 'This page renders outside the layout.' + ) + ).toBeInTheDocument(); + }); + }); + + it('should navigate from custom route back to resource', async () => { + render(); + await waitFor(() => { + expect( + screen.getByText('Go to Custom Page') + ).toBeInTheDocument(); + }); + + fireEvent.click(screen.getByText('Go to Custom Page')); + + await waitFor(() => { + expect(screen.getByText('Custom Page')).toBeInTheDocument(); + }); + + fireEvent.click(screen.getByText('Go to Posts')); + + await waitFor(() => { + expect(screen.getByText('Posts')).toBeInTheDocument(); + }); + }); + }); + }); + + describe('useParams', () => { + it('should not have id param on list page', async () => { + render(); + await waitFor(() => { + expect(screen.getByText('Posts')).toBeInTheDocument(); + }); + + const paramsDisplay = screen.getByTestId('params-display'); + expect(paramsDisplay.textContent).not.toContain('"id"'); + }); + + it('should return id param on show page', async () => { + render(); + await waitFor(() => { + expect(screen.getByText('Post #1')).toBeInTheDocument(); + }); + + fireEvent.click(screen.getByText('Post #1')); + + await waitFor( + () => { + expect( + screen.getByText('Post Details') + ).toBeInTheDocument(); + }, + { timeout: 3000 } + ); + + const paramsDisplay = screen.getByTestId('params-display'); + expect(paramsDisplay.textContent).toContain('"id"'); + expect(paramsDisplay.textContent).toContain('"1"'); + }); + + it('should return different id param for different records', async () => { + render(); + await waitFor(() => { + expect(screen.getByText('Post #2')).toBeInTheDocument(); + }); + + fireEvent.click(screen.getByText('Post #2')); + + await waitFor( + () => { + expect( + screen.getByText('Post Details') + ).toBeInTheDocument(); + }, + { timeout: 3000 } + ); + + const paramsDisplay = screen.getByTestId('params-display'); + expect(paramsDisplay.textContent).toContain('"id"'); + expect(paramsDisplay.textContent).toContain('"2"'); + }); + }); + + describe('useMatch', () => { + it('should match current route with end=false', async () => { + render(); + await waitFor(() => { + expect(screen.getByText('Posts List')).toBeInTheDocument(); + }); + + expect(screen.getByTestId('posts-match').textContent).toContain( + 'MATCH' + ); + expect(screen.getByTestId('comments-match').textContent).toContain( + 'no match' + ); + }); + + it('should match exact route with end=true', async () => { + render(); + await waitFor(() => { + expect(screen.getByText('Posts List')).toBeInTheDocument(); + }); + + expect( + screen.getByTestId('posts-exact-match').textContent + ).toContain('MATCH'); + }); + + it('should not match exact route on nested path', async () => { + render(); + await waitFor(() => { + expect(screen.getByText('Post #1')).toBeInTheDocument(); + }); + + fireEvent.click(screen.getByText('Post #1')); + + await waitFor( + () => { + expect(screen.getByText('Post Show')).toBeInTheDocument(); + }, + { timeout: 3000 } + ); + + // end=false should still match /posts on /posts/1/show + expect(screen.getByTestId('posts-match').textContent).toContain( + 'MATCH' + ); + // end=true should NOT match /posts on /posts/1/show + expect( + screen.getByTestId('posts-exact-match').textContent + ).toContain('no match'); + }); + + it('should update match when navigating to different resource', async () => { + render(); + await waitFor(() => { + expect(screen.getByText('Posts List')).toBeInTheDocument(); + }); + + fireEvent.click(screen.getByText('Comments')); + + await waitFor(() => { + expect(screen.getByText('Comments List')).toBeInTheDocument(); + }); + + expect(screen.getByTestId('posts-match').textContent).toContain( + 'no match' + ); + expect(screen.getByTestId('comments-match').textContent).toContain( + 'MATCH' + ); + }); + }); + + describe('useWarnWhenUnsavedChanges', () => { + it('should block navigation when form is dirty', async () => { + const originalConfirm = window.confirm; + let confirmCalled = false; + window.confirm = () => { + confirmCalled = true; + return false; + }; + try { + const user = userEvent.setup(); + render(); + const [title] = await screen.findAllByRole('textbox'); + await user.type(title, 'A new title'); + fireEvent.click(screen.getByText('Go to Comments')); + await waitFor(() => { + expect(confirmCalled).toBe(true); + }); + } finally { + window.confirm = originalConfirm; + } + }); + + it('should allow navigation when clicking proceed', async () => { + const originalConfirm = window.confirm; + window.confirm = () => true; + try { + const user = userEvent.setup(); + render(); + const [title] = await screen.findAllByRole('textbox'); + await user.type(title, 'A new title'); + fireEvent.click(screen.getByText('Go to Comments')); + await waitFor(() => { + expect( + screen.getByText('You navigated away from the form.') + ).toBeInTheDocument(); + }); + } finally { + window.confirm = originalConfirm; + } + }); + + it('should cancel navigation when clicking cancel', async () => { + const originalConfirm = window.confirm; + window.confirm = () => false; + try { + const user = userEvent.setup(); + render(); + const [title] = await screen.findAllByRole('textbox'); + await user.type(title, 'A new title'); + fireEvent.click(screen.getByText('Go to Comments')); + await waitFor(() => { + expect( + screen.getByText('Form with Unsaved Changes Warning') + ).toBeInTheDocument(); + }); + expect( + screen.getByDisplayValue('A new title') + ).toBeInTheDocument(); + } finally { + window.confirm = originalConfirm; + } + }); + + it('should not block navigation when form is not dirty', async () => { + const originalConfirm = window.confirm; + let confirmCalled = false; + window.confirm = () => { + confirmCalled = true; + return true; + }; + try { + render(); + await screen.findByText('Form with Unsaved Changes Warning'); + fireEvent.click(screen.getByText('Go to Comments')); + await waitFor(() => { + expect( + screen.getByText('You navigated away from the form.') + ).toBeInTheDocument(); + }); + expect(confirmCalled).toBe(false); + } finally { + window.confirm = originalConfirm; + } + }); + }); + + describe('Navigate', () => { + it('should redirect to target route', async () => { + render(); + await waitFor(() => { + expect(screen.getByTestId('posts-page')).toBeInTheDocument(); + }); + + fireEvent.click( + screen.getByText('Go to Redirect Page (auto-redirects here)') + ); + + // Should immediately redirect back to posts + await waitFor(() => { + expect(screen.getByTestId('posts-page')).toBeInTheDocument(); + }); + }); + + it('should preserve search params on redirect', async () => { + render(); + await waitFor(() => { + expect(screen.getByTestId('posts-page')).toBeInTheDocument(); + }); + + fireEvent.click(screen.getByText('Go to redirect with params')); + + // Should immediately redirect back to posts with search params + await waitFor(() => { + expect(screen.getByTestId('posts-page')).toBeInTheDocument(); + }); + + expect( + screen.getByText(/"pathname": "\/posts"/) + ).toBeInTheDocument(); + expect( + screen.getByText(/"search": "\?foo=bar"/) + ).toBeInTheDocument(); + }); + + it('should redirect conditionally when state changes', async () => { + render(); + await waitFor(() => { + expect(screen.getByTestId('posts-page')).toBeInTheDocument(); + }); + + fireEvent.click(screen.getByText('Go to Conditional Redirect')); + + await waitFor(() => { + expect( + screen.getByText('Conditional Redirect') + ).toBeInTheDocument(); + }); + + fireEvent.click(screen.getByTestId('trigger-redirect')); + + await waitFor(() => { + expect(screen.getByTestId('posts-page')).toBeInTheDocument(); + }); + }); + + it('should support location object with only search (no pathname)', async () => { + render(); + await waitFor(() => { + expect(screen.getByTestId('posts-page')).toBeInTheDocument(); + }); + + // Navigate to the Navigate search-only test page + // This page uses + // which should stay on the same pathname but add search params + fireEvent.click( + screen.getByText('Go to Navigate search-only test') + ); + + // Should show the success message after redirecting + await waitFor(() => { + expect( + screen.getByTestId('navigate-search-only-page') + ).toBeInTheDocument(); + }); + + // Should stay on /navigate-search-only but with search params added + expect( + screen.getByText(/"pathname": "\/navigate-search-only"/) + ).toBeInTheDocument(); + + // The search params should contain 'redirected' + expect( + screen.getByText(/"search": "\?redirected=true"/) + ).toBeInTheDocument(); + }); + }); + + describe('useLocation', () => { + it('should return current pathname', async () => { + render(); + await waitFor(() => { + expect(screen.getByText('Location Test')).toBeInTheDocument(); + }); + + expect( + screen.getByTestId('location-pathname').textContent + ).toContain('/posts'); + }); + + it('should return empty search by default', async () => { + render(); + await waitFor(() => { + expect(screen.getByText('Location Test')).toBeInTheDocument(); + }); + + expect(screen.getByTestId('location-search').textContent).toContain( + '""' + ); + }); + + it('should update pathname on navigation', async () => { + render(); + await waitFor(() => { + expect(screen.getByText('Go to Post Show')).toBeInTheDocument(); + }); + + fireEvent.click(screen.getByText('Go to Post Show')); + + await waitFor(() => { + expect(screen.getByText('Post Show')).toBeInTheDocument(); + }); + + expect( + screen.getByTestId('location-pathname').textContent + ).toContain('/posts/1/show'); + }); + + it('should include state when navigated with state', async () => { + render(); + await waitFor(() => { + expect( + screen.getByText('Go to Post Show (with state)') + ).toBeInTheDocument(); + }); + + fireEvent.click(screen.getByText('Go to Post Show (with state)')); + + await waitFor(() => { + expect(screen.getByText('Post Show')).toBeInTheDocument(); + }); + + expect(screen.getByTestId('location-state').textContent).toContain( + 'from' + ); + expect(screen.getByTestId('location-state').textContent).toContain( + 'list' + ); + }); + }); + + describe('useInRouterContext', () => { + it('should return true when inside router', async () => { + render(); + await waitFor(() => { + expect( + screen.getByText('Router Context Test') + ).toBeInTheDocument(); + }); + + expect( + screen.getByTestId('in-router-context').textContent + ).toContain('true'); + }); + }); + + describe('useCanBlock', () => { + it('should return true for React Router', async () => { + render(); + await waitFor(() => { + expect( + screen.getByText('Router Context Test') + ).toBeInTheDocument(); + }); + + expect(screen.getByTestId('can-block').textContent).toContain( + 'true' + ); + }); + }); + + describe('Nested Routes with Outlet', () => { + it('should render the default tab content', async () => { + render(); + await screen.findByText('Post #1'); + + fireEvent.click(screen.getByText('Post #1')); + await screen.findByText('Tabbed Layout (like TabbedShowLayout)'); + + // Should render the first tab (content) by default + expect(screen.getByTestId('content-tab')).toBeInTheDocument(); + expect( + screen.getByText( + 'This is the content tab (first tab, default).' + ) + ).toBeInTheDocument(); + }); + + it('should navigate between tabs using Outlet', async () => { + render(); + await screen.findByText('Post #1'); + + fireEvent.click(screen.getByText('Post #1')); + await screen.findByTestId('content-tab'); + + // Click on the second tab (Metadata) + fireEvent.click(screen.getByText('Metadata Tab')); + await screen.findByTestId('metadata-tab'); + + expect( + screen.getByText('This is the metadata tab (second tab).') + ).toBeInTheDocument(); + expect(screen.queryByTestId('content-tab')).not.toBeInTheDocument(); + }); + + it('should navigate back to first tab', async () => { + render(); + await waitFor(() => { + expect(screen.getByText('Post #1')).toBeInTheDocument(); + }); + + fireEvent.click(screen.getByText('Post #1')); + + await waitFor( + () => { + expect( + screen.getByTestId('content-tab') + ).toBeInTheDocument(); + }, + { timeout: 3000 } + ); + + // Go to second tab + fireEvent.click(screen.getByText('Metadata Tab')); + + await waitFor(() => { + expect(screen.getByTestId('metadata-tab')).toBeInTheDocument(); + }); + + // Go back to first tab + fireEvent.click(screen.getByText('Content Tab')); + + await waitFor(() => { + expect(screen.getByTestId('content-tab')).toBeInTheDocument(); + }); + + expect( + screen.queryByTestId('metadata-tab') + ).not.toBeInTheDocument(); + }); + }); + + describe('Nested Resources (Route children of Resource)', () => { + it('should navigate to child routes defined inside Resource', async () => { + render(); + await screen.findByText('Post #1'); + + fireEvent.click(screen.getByText('Post #1')); + + await waitFor( + () => { + expect( + screen.getByText('Post Details') + ).toBeInTheDocument(); + }, + { timeout: 3000 } + ); + }); + }); + + describe('Query Parameters', () => { + it('should update URL with query parameters when sorting', async () => { + render(); + await screen.findByText('Posts with Query Parameters'); + + // Initially no search params + expect(screen.getByTestId('current-search').textContent).toContain( + '(empty)' + ); + + // Click sort by title + fireEvent.click(screen.getByTestId('sort-title')); + + await waitFor(() => { + expect( + screen.getByTestId('current-search').textContent + ).toContain('sort=title'); + }); + + expect(screen.getByTestId('current-sort').textContent).toContain( + 'title' + ); + }); + + it('should update URL with query parameters when changing page', async () => { + render(); + await screen.findByText('Posts with Query Parameters'); + + // Click page 2 + fireEvent.click(screen.getByTestId('page-2')); + + await waitFor(() => { + expect( + screen.getByTestId('current-search').textContent + ).toContain('page=2'); + }); + + expect(screen.getByTestId('current-page').textContent).toContain( + '2' + ); + }); + + it('should preserve query parameters across multiple updates', async () => { + render(); + await screen.findByText('Posts with Query Parameters'); + + // Set sort + fireEvent.click(screen.getByTestId('sort-title')); + + await waitFor(() => { + expect( + screen.getByTestId('current-search').textContent + ).toContain('sort=title'); + }); + + // Set page + fireEvent.click(screen.getByTestId('page-3')); + + await waitFor(() => { + const search = + screen.getByTestId('current-search').textContent || ''; + expect(search).toContain('sort=title'); + expect(search).toContain('page=3'); + }); + }); + }); + + describe('Pathless Layout Routes', () => { + it('should match pathless layout routes with child routes', async () => { + window.location.hash = '#/posts'; + + render(); + + await waitFor(() => { + expect(screen.getByText('Layout Wrapper')).toBeInTheDocument(); + expect(screen.getByTestId('posts-page')).toBeInTheDocument(); + }); + }); + + it('should navigate between child routes within pathless layout', async () => { + window.location.hash = '#/posts'; + + render(); + + await waitFor(() => { + expect(screen.getByText('Layout Wrapper')).toBeInTheDocument(); + expect(screen.getByTestId('posts-page')).toBeInTheDocument(); + }); + + fireEvent.click(screen.getByText('Comments')); + + await waitFor(() => { + expect(screen.getByText('Layout Wrapper')).toBeInTheDocument(); + expect(screen.getByTestId('comments-page')).toBeInTheDocument(); + }); + }); + + it('should match the most specific layout route within pathless layout routes', async () => { + window.location.hash = '#/posts'; + + render(); + + await waitFor(() => { + expect(screen.getByTestId('posts-page')).toBeInTheDocument(); + }); + + fireEvent.click(screen.getByText('User')); + + await waitFor(() => { + expect(screen.getByTestId('users-page')).toBeInTheDocument(); + }); + + fireEvent.click(screen.getByText('Block a user')); + + await waitFor(() => { + expect( + screen.getByTestId('block-user-page') + ).toBeInTheDocument(); + }); + }); + }); + + it('should match the empty path route as most specific within pathless layout routes', async () => { + window.location.hash = '#/posts'; + + render(); + + await waitFor(() => { + expect(screen.getByTestId('posts-page')).toBeInTheDocument(); + }); + + fireEvent.click(screen.getByText('Home (path="")')); + + await waitFor(() => { + expect(screen.getByTestId('home-page')).toBeInTheDocument(); + }); + }); + + it('should match the index route as most specific within pathless layout routes', async () => { + window.location.hash = '#/posts'; + + render(); + + await waitFor(() => { + expect(screen.getByTestId('posts-page')).toBeInTheDocument(); + }); + + fireEvent.click(screen.getByText('Home (index)')); + + await waitFor(() => { + expect(screen.getByTestId('home-page')).toBeInTheDocument(); + }); + }); + + describe('Resource Children (Route as children of Resource)', () => { + it('should navigate to child routes without matching parent edit route', async () => { + render(); + + // Wait for posts list to load + await screen.findByText('Post #1'); + + // Click on a post to go to edit page + fireEvent.click(screen.getByText('Post #1')); + + // Wait for edit page + await screen.findByText('Post Details'); + + // Click to view comments (child route) + fireEvent.click(screen.getByText('View Comments')); + + // Should navigate to comments page, not stay on edit + await waitFor(() => { + expect( + screen.getByText(/Comments for Post/) + ).toBeInTheDocument(); + }); + }); + }); +}); diff --git a/packages/ra-router-react-router/src/reactRouterProvider.stories.tsx b/packages/ra-router-react-router/src/reactRouterProvider.stories.tsx new file mode 100644 index 00000000000..32d5b8977e2 --- /dev/null +++ b/packages/ra-router-react-router/src/reactRouterProvider.stories.tsx @@ -0,0 +1,1584 @@ +import * as React from 'react'; +import fakeDataProvider from 'ra-data-fakerest'; +import { + RouterProvider, + Outlet, + useNavigate as useReactRouterNavigate, +} from 'react-router'; +import { createHashRouter, Link as ReactRouterLink } from 'react-router-dom'; +import { + useNavigate, + useLocation, + LinkBase, + ListBase, + ShowBase, + EditBase, + CreateBase, + useRecordContext, + CoreAdmin, + Resource, + CustomRoutes, + RouterProviderContext, + testUI, +} from 'ra-core'; +import { reactRouterProvider } from './reactRouterProvider'; + +const { + useParams, + useMatch, + useInRouterContext, + useCanBlock, + Route, + Navigate, +} = reactRouterProvider; +const { TextInput, SimpleList, SimpleShowLayout, SimpleForm, CreateButton } = + testUI; + +export default { + title: 'ra-routing-react-router/React Router Provider', +}; + +const dataProvider = fakeDataProvider( + { + posts: [ + { id: 1, title: 'Post #1', body: 'Hello World' }, + { id: 2, title: 'Post #2', body: 'Second post' }, + { id: 3, title: 'Post #3', body: 'Third post' }, + { id: 4, title: 'Post #4', body: 'Fourth post' }, + ], + comments: [ + { id: 1, post_id: 1, body: 'Nice post!' }, + { id: 2, post_id: 1, body: 'Great article' }, + ], + }, + process.env.NODE_ENV === 'development' +); + +const PostList = () => ( + +
    +

    Posts

    + ( + + {record.title} + + )} + /> + +
    +
    +); + +const PostShow = () => ( + ( +
    +

    Post Details

    + + ID: {record?.id} + Title: {record?.title} + Body: {record?.body} + + Back to list +
    + )} + /> +); + +const PostEdit = () => ( + +
    +

    Edit Post

    + + + + +
    +
    +); + +const PostCreate = () => ( + +
    +

    Create Post

    + + + + +
    +
    +); + +const LocationDisplay = () => { + const location = useLocation(); + return ( +
    + Current Location: +
    {JSON.stringify(location, null, 2)}
    +
    window.location.hash: {window.location.hash}
    +
    + ); +}; + +const LayoutWithLocationDisplay = ({ + children, +}: { + children: React.ReactNode; +}) => ( +
    + {children} + +
    +); + +/** + * Basic: Admin creates its own React Router (standalone mode) + * Tests basic navigation, links, and programmatic navigation. + */ +export const Basic = () => ( + + + +); + +/** + * EmbeddedInReactRouter: Admin inside an existing React Router app + * Tests that react-admin detects existing router and uses it. + */ +// Nav component that uses the router for navigation +const EmbeddedNav = () => { + const navigate = useReactRouterNavigate(); + return ( + + ); +}; + +// Create routes outside the component to avoid recreating on every render +const embeddedRootRoute = { + element: ( +
    + + +
    + ), +}; + +const embeddedHomeRoute = { + index: true, + element: ( +
    +

    Home Page

    +

    This is a React Router app with embedded react-admin.

    + Go to Admin +
    + ), +}; + +const EmbeddedAdmin = () => ( + + + +); + +// Splat route to handle /admin, /admin/posts, /admin/posts/1/show, etc. +const embeddedAdminRoute = { path: 'admin/*', element: }; + +const embeddedRouteTree = [ + { + path: '/', + ...embeddedRootRoute, + children: [embeddedHomeRoute, embeddedAdminRoute], + }, +]; + +/** + * Admin inside an existing React Router app + * Tests that react-admin detects existing router and uses it. + */ +export const EmbeddedInReactRouter = () => { + const router = React.useMemo(() => createHashRouter(embeddedRouteTree), []); + + return ; +}; + +/** + * Tests back/forward navigation + */ +export const HistoryNavigation = () => { + const HistoryButtons = () => { + const navigate = useNavigate(); + return ( +
    + + +
    + ); + }; + + const ListWithHistory = () => ( +
    + + +
    + ); + + const ShowWithHistory = () => ( +
    + + +
    + ); + + return ( + + } + show={} + /> + + ); +}; + +/** + * Tests that routes match correctly + * Tests resource routes, custom routes, and catch-all routes. + */ +export const RouteMatching = () => { + const Dashboard = () => ( +
    +

    Dashboard

    +

    Welcome to the admin dashboard.

    +
      +
    • + Posts +
    • +
    +
    + ); + + return ( + + + + ); +}; + +/** + * Tests to, replace, state props work correctly. + */ +export const LinkComponent = () => { + const LinkTestPage = () => ( +
    +

    Link Component Tests

    + +

    Basic Link

    + Go to Post #1 + +

    Link with Replace

    + + Go to Post #2 (replace history) + + +

    Link with State

    + + Go to Post #3 (with state) + + +

    Link with Location object

    + + Go to Post #4 (with search) + + +

    Link with no pathname change

    + + Go to same page with search param + +
    + ); + + return ( + + + + ); +}; + +/** + * Tests navigation between multiple resources + */ +export const MultipleResources = () => { + const CommentList = () => ( +
    +

    Comments

    +
      +
    • Comment #1: Nice post!
    • +
    • Comment #2: Great article
    • +
    + Go to Posts +
    + ); + + return ( + + + + Go to Comments + + } + show={PostShow} + /> + + + ); +}; + +export const CustomRoutesSupport = () => { + const CustomPage = () => { + const navigate = useNavigate(); + return ( +
    +

    Custom Page

    +

    + This is a custom route using react-router's Route component. +

    + +
    + ); + }; + + const CustomNoLayoutPage = () => ( +
    +

    Custom Page (No Layout)

    +

    This page renders outside the layout.

    + Go to Posts + +
    + ); + + return ( + + + } /> + + + } + /> + + + +
    + Go to Custom Page +
    + + Go to Custom Page (No Layout) + +
    + + } + /> +
    + ); +}; + +/** + * Displays URL parameters extracted from the current route. + */ +export const UseParamsTest = () => { + const ParamsDisplay = () => { + const params = useParams(); + return ( +
    + URL Params: +
    +                    {JSON.stringify(params, null, 2)}
    +                
    +
    + ); + }; + + const PostShowWithParams = () => { + const record = useRecordContext(); + return ( +
    +

    Post Details

    + + {record && ( + <> +

    + ID: {record.id} +

    +

    + Title: {record.title} +

    + + )} + Back to List +
    + ); + }; + + return ( + + +

    Posts

    + +
      +
    • + Post #1 +
    • +
    • + Post #2 +
    • +
    + + } + show={} + /> +
    + ); +}; + +/** + * Shows active link highlighting based on current route match. + */ +export const UseMatchTest = () => { + const NavLink = ({ + to, + children, + }: { + to: string; + children: React.ReactNode; + }) => { + const match = useMatch({ path: to, end: false }); + return ( + + {children} + + ); + }; + + const MatchDisplay = () => { + const postsMatch = useMatch({ path: '/posts', end: false }); + const commentsMatch = useMatch({ path: '/comments', end: false }); + const exactPostsMatch = useMatch({ path: '/posts', end: true }); + + return ( +
    + Match Results: +
    + /posts (end: false): {postsMatch ? 'MATCH' : 'no match'} +
    +
    + /posts (end: true): {exactPostsMatch ? 'MATCH' : 'no match'} +
    +
    + /comments (end: false):{' '} + {commentsMatch ? 'MATCH' : 'no match'} +
    +
    + ); + }; + + const NavBar = () => ( + + ); + + return ( + + + + +
    +

    Posts List

    +
      +
    • + + Post #1 + +
    • +
    +
    + + } + show={ +
    + + +
    +

    Post Show

    + Back to List +
    +
    + } + /> + + + +
    +

    Comments List

    +
    + + } + /> +
    + ); +}; + +/** + * Blocks navigation when there are unsaved changes. + */ +export const UseWarnWhenUnsavedChangesTest = () => { + const FormWithWarnWhenUnsavedChanges = () => ( +
    +

    Form with Unsaved Changes Warning

    + + + + + Go to Comments +
    + ); + + return ( + + } /> + +

    Comments

    +

    You navigated away from the form.

    + Back to Form + + } + /> +
    + ); +}; + +export const NavigateComponent = () => { + const DummyPage = () => { + return ( +
    +

    Dummy page

    + +
    + ); + }; + + const RedirectPage = () => { + return ( +
    +

    Redirecting...

    + +
    + ); + }; + + const ConditionalRedirect = () => { + const [shouldRedirect, setShouldRedirect] = React.useState(false); + return ( +
    +

    Conditional Redirect

    + {shouldRedirect ? ( + + ) : ( +
    +

    Click the button to trigger a redirect.

    + +
    + )} +
    + ); + }; + + // Page that uses Navigate with only search params (no pathname) + // This should stay on the current page but update search params + const SearchOnlyRedirectPage = () => { + const location = useLocation(); + const hasUpdatedParam = location.search.includes('updated'); + + return ( +
    +

    Search-Only Redirect Page

    +

    + This page tests Navigate with only search params. +

    + {!hasUpdatedParam && ( + + + + )} + {hasUpdatedParam && ( +

    + Search params updated successfully! +

    + )} +
    + ); + }; + + // Page that demonstrates Navigate with only search (redirects once) + const NavigateSearchOnlyPage = () => { + const location = useLocation(); + const hasRedirected = location.search.includes('redirected'); + + // Only render Navigate if we haven't already redirected + // This prevents infinite navigation loops + if (!hasRedirected) { + return ( +
    +

    Redirecting with search only...

    + +
    + ); + } + + return ( +
    +

    Navigate Search-Only Test

    +

    + Successfully navigated with search-only (no pathname). +

    +
    + ); + }; + + return ( + + + } /> + } /> + } + /> + } + /> + } + /> + + +

    Posts

    +

    + You are on the posts page. +

    +
      +
    • + + Go to Redirect Page (auto-redirects here) + +
    • +
    • + + Go to Conditional Redirect + +
    • +
    • + + Go to redirect with params + +
    • +
    • + + Go to search-only redirect test (Link) + +
    • +
    • + + Go to Navigate search-only test + +
    • +
    + + } + /> +
    + ); +}; + +export const UseLocationTest = () => { + const DetailedLocationDisplay = () => { + const location = useLocation(); + return ( +
    +

    useLocation() Result:

    +
    + pathname: {location.pathname} +
    +
    + search: "{location.search}" +
    +
    + hash: "{location.hash}" +
    +
    + state:{' '} + {JSON.stringify(location.state) || 'null'} +
    +
    + ); + }; + + return ( + + +

    Location Test

    + +
    +

    Navigation Links:

    +
      +
    • + + Go to Post Show + +
    • +
    • + + Go to Post Show (with state) + +
    • +
    +
    + + } + show={ +
    +

    Post Show

    + + Back to List +
    + } + /> +
    + ); +}; + +/** + * RouterContextTest: Tests useInRouterContext and useCanBlock hooks + */ +export const RouterContextTest = () => { + const ContextInfo = () => { + const isInRouter = useInRouterContext(); + const canBlock = useCanBlock(); + + return ( +
    +

    Router Context Info:

    +
    + useInRouterContext():{' '} + {isInRouter ? 'true' : 'false'} +
    +
    + useCanBlock():{' '} + {canBlock ? 'true' : 'false'} +
    +
    + ); + }; + + return ( + + +

    Router Context Test

    + + + } + /> +
    + ); +}; + +const { Routes, Outlet: RouterOutlet } = reactRouterProvider; + +export const NestedResources = () => ( + + }> + } /> + + +); + +const PostEditWithLinkToComments = () => { + const navigate = useNavigate(); + return ( + ( +
    +

    Post Details

    + {record &&

    {record.title}

    } + + +
    + )} + /> + ); +}; + +const CommentList = () => { + const { post_id } = useParams(); + const navigate = useNavigate(); + return ( + ( +
    +

    Comments for Post {post_id}

    +
      + {data?.map(record => ( +
    • {record.body}
    • + ))} +
    + +
    + )} + /> + ); +}; + +export const NestedResourcesPrecedence = () => ( + + + } /> + + +); + +/** + * Tests that query parameters work correctly (for list sorting, filtering, pagination). + * This tests the navigate({ search: '?...' }) pattern used by useListParams. + */ +export const QueryParameters = () => { + const ListWithQueryParams = () => { + const location = useLocation(); + const navigate = useNavigate(); + + // Parse current query params + const searchParams = new URLSearchParams(location.search); + const sort = searchParams.get('sort') || 'id'; + const order = searchParams.get('order') || 'ASC'; + const page = searchParams.get('page') || '1'; + + const setSort = (field: string, newOrder: string) => { + navigate({ + search: `?sort=${field}&order=${newOrder}&page=${page}`, + }); + }; + + const setPage = (newPage: number) => { + navigate({ + search: `?sort=${sort}&order=${order}&page=${newPage}`, + }); + }; + + return ( +
    +

    Posts with Query Parameters

    +
    +
    + Current search: {location.search || '(empty)'} +
    +
    + Sort: {sort} {order} +
    +
    Page: {page}
    +
    +
    + Sort by:{' '} + {' '} + +
    +
    + Page:{' '} + {' '} + {' '} + +
    +
      +
    • Post #1
    • +
    • Post #2
    • +
    • Post #3
    • +
    +
    + ); + }; + + return ( + + } /> + + ); +}; + +/** + * This tests the pattern where a parent Route has child Routes and uses Outlet + * to render the matched child (like TabbedShowLayout). + */ +export const NestedRoutesWithOutlet = () => { + const TabbedLayout = () => { + const location = useLocation(); + return ( +
    +

    Tabbed Layout (like TabbedShowLayout)

    + + + +
    + +
    +
    + } + > + +

    Content Tab

    +

    + This is the content tab (first tab, + default). +

    +

    Title: Hello World

    +

    Body: Welcome to react-admin!

    + + } + /> + +

    Metadata Tab

    +

    + This is the metadata tab (second tab). +

    +

    ID: 1

    +

    Created: 2024-01-15

    +

    Author: Admin

    + + } + /> +
    + + + ); + }; + + return ( + + + + ); +}; + +export const PathlessLayoutRoutes = () => { + const { RouterWrapper } = reactRouterProvider; + + return ( + + + + +

    Layout Wrapper

    + +
    + +
    + + } + > + Posts Page + } + /> + + Comments Page + + } + /> + +
    + +
    +
    + ); +}; + +export const PathlessLayoutRoutesPriority = () => { + const { RouterWrapper } = reactRouterProvider; + + return ( + + +
    + +
    + + + Posts Page +
    + } + /> + + Comments Page +
    + } + /> + + + + } + > + + Users View + + } + /> + + + + + } + > + + Block a user + + } + /> + + + + + +
    +
    + ); +}; + +export const PathlessLayoutRoutesWithEmptyRoute = () => { + const { RouterWrapper } = reactRouterProvider; + + return ( + + +

    + Expected: "/" renders Home Page (path=""). If you see + Catch-all Page instead, path="" is being treated as + catch-all. +

    + + + Catch-all Page + + } + /> + +

    Layout Wrapper

    + + +
    + +
    + + } + > + + Home Page (path="") + + } + /> + Posts Page + } + /> + + Comments Page + + } + /> + +
    + +
    +
    + ); +}; + +export const PathlessLayoutRoutesWithIndexRoute = () => { + const { RouterWrapper } = reactRouterProvider; + + return ( + + + + + Catch-all Page + + } + /> + +

    Layout Wrapper

    + + +
    + +
    + + } + > + + Home Page (index) + + } + /> + Posts Page + } + /> + + Comments Page + + } + /> + +
    + +
    +
    + ); +}; diff --git a/packages/ra-router-react-router/src/reactRouterProvider.tsx b/packages/ra-router-react-router/src/reactRouterProvider.tsx new file mode 100644 index 00000000000..42eba312913 --- /dev/null +++ b/packages/ra-router-react-router/src/reactRouterProvider.tsx @@ -0,0 +1,278 @@ +import * as React from 'react'; +import { + useContext, + useEffect, + useRef, + forwardRef, + createContext, + ReactNode, +} from 'react'; +import { + useNavigate as useReactRouterNavigate, + useLocation, + useParams, + useBlocker, + useMatch, + useInRouterContext, + Navigate as ReactRouterNavigate, + Route, + Routes, + Outlet, + matchPath, + RouterProvider as ReactRouterProvider, + UNSAFE_DataRouterContext, + UNSAFE_DataRouterStateContext, + type FutureConfig, + type NavigateProps, +} from 'react-router'; +import { + Link as ReactRouterDomLink, + createHashRouter, + type LinkProps, +} from 'react-router-dom'; + +// These types are used in the RouterProvider interface definition in ra-core. +// To avoid a circular build dependency (ra-core -> ra-router-react-router -> ra-core), +// this package redeclares necessary types. These types should exactly mirror the +// types in ra-core's RouterProvider interface. +type RouterNavigateFunction = ( + to: string | Partial | number, + options?: { replace?: boolean; state?: any } +) => void; + +interface RouterWrapperProps { + basename?: string; + children: ReactNode; +} + +type UseParams = < + T extends Record = Record< + string, + string | undefined + >, +>() => T; + +// This context is used when the app is mounted on a sub path, e.g. '/admin'. +// To avoid a circular build dependency (ra-core -> ra-router-react-router -> ra-core), +// this package redeclares necessary contexts. This context should exactly mirror the +// context in ra-core's BasenameContext. +const BasenameContext = createContext(''); +const useBasename = (): string => useContext(BasenameContext); + +const routerProviderFuture: Partial< + Pick +> = { v7_startTransition: false, v7_relativeSplatPath: false }; + +/** + * Hook to check if navigation blocking is supported. + * In react-router, blocking requires a data router. + */ +const useCanBlock = (): boolean => { + const dataRouterContext = useContext(UNSAFE_DataRouterContext); + const dataRouterStateContext = useContext(UNSAFE_DataRouterStateContext); + return !!(dataRouterContext && dataRouterStateContext); +}; + +/** + * Wrapper around react-router's useNavigate that returns a stable function reference. + * + * react-router's useNavigate forces rerenders on every navigation, even if we don't use the result. + * @see https://github.com/remix-run/react-router/issues/7634 + * + * This wrapper uses a ref to return a stable function reference, avoiding unnecessary rerenders + * in components that use navigate but don't need to rerender on navigation. + */ +const useNavigate = (): RouterNavigateFunction => { + const navigate = useReactRouterNavigate(); + const basename = useBasename(); + const navigateRef = useRef( + navigate as RouterNavigateFunction + ); + + useEffect(() => { + navigateRef.current = navigate as RouterNavigateFunction; + }, [navigate]); + + // Return a stable function that always calls the latest navigate + return React.useCallback( + (...args: Parameters) => { + const [to, ...rest] = args; + + // Handle numeric navigation (go back/forward) + if (typeof to === 'number') { + return navigateRef.current(to, ...rest); + } + + // Helper to prepend basename to absolute paths + // Only prepend if path doesn't already start with basename + const resolvePath = (path: string) => { + if (!basename || !path.startsWith('/')) return path; + // Don't prepend if path already includes basename + if (path.startsWith(basename + '/') || path === basename) { + return path; + } + return `${basename}${path}`; + }; + + // Handle object navigation { pathname?, search?, hash?, state? } + // This covers both { pathname: '/foo' } and { search: '?bar=1' } + if (typeof to === 'object' && to !== null) { + // If no pathname provided, keep current pathname + return navigateRef.current( + to.pathname + ? { ...to, pathname: resolvePath(to.pathname) } + : to, + ...rest + ); + } + + // Handle string path + const resolvedPath = resolvePath(to as string); + return navigateRef.current(resolvedPath, ...rest); + }, + [basename] + ) as RouterNavigateFunction; +}; + +const Link = forwardRef( + ({ to, ...rest }, ref) => { + const basename = useBasename(); + + // Helper to prepend basename to absolute paths + const resolvePath = (path: string) => { + if (!basename || !path.startsWith('/')) return path; + if (path.startsWith(basename + '/') || path === basename) { + return path; + } + return `${basename}${path}`; + }; + + // Handle object `to` (e.g., { pathname: '/path', search: '?foo=bar' }) + let resolvedTo: typeof to; + if (typeof to === 'object' && to !== null) { + // If no pathname provided, keep it as-is to stay on current page + resolvedTo = to.pathname + ? { ...to, pathname: resolvePath(to.pathname) } + : to; + } else { + resolvedTo = resolvePath(to as string); + } + return ; + } +); +Link.displayName = 'Link'; + +const Navigate = ({ to, ...rest }: NavigateProps) => { + const basename = useBasename(); + const currentLocation = useLocation(); + + // Handle both string and object forms of `to` + let resolvedPath: string; + + if (typeof to === 'string') { + resolvedPath = to; + } else { + // If no pathname provided, use current pathname to stay on current page + resolvedPath = to.pathname ?? currentLocation.pathname; + + // Append search and hash directly to the path to preserve the raw + // query string format + if (to.search) { + resolvedPath += to.search.startsWith('?') + ? to.search + : `?${to.search}`; + } + if (to.hash) { + resolvedPath += to.hash.startsWith('#') ? to.hash : `#${to.hash}`; + } + } + + // Prepend basename to the path + // Only prepend if path doesn't already start with basename + if (basename && resolvedPath.startsWith('/')) { + if ( + !resolvedPath.startsWith(basename + '/') && + resolvedPath !== basename + ) { + resolvedPath = `${basename}${resolvedPath}`; + } + } + + return ; +}; + +/** + * Internal router component that creates a HashRouter. + * Only used when not already inside a router context. + */ +const InternalRouter = ({ + children, + basename, +}: { + children: ReactNode; + basename?: string; +}) => { + const router = createHashRouter([{ path: '*', element: <>{children} }], { + basename, + future: { + v7_fetcherPersist: false, + v7_normalizeFormMethod: false, + v7_partialHydration: false, + v7_relativeSplatPath: false, + v7_skipActionErrorRevalidation: false, + }, + }); + return ( + + ); +}; + +/** + * RouterWrapper component for react-router. + * Creates a HashRouter if not already inside a router context. + */ +const RouterWrapper = ({ basename, children }: RouterWrapperProps) => { + const isInRouter = useInRouterContext(); + + if (isInRouter) { + return ( + + {children} + + ); + } + + return ( + + {children} + + ); +}; + +/** + * Default router provider using react-router-dom. + * This provider is used by default when no custom routerProvider is provided to . + */ +export const reactRouterProvider = { + // Hooks + useNavigate, + useLocation, + useParams: useParams as UseParams, + useBlocker, + useMatch, + useInRouterContext, + useCanBlock, + + // Components + Link, + Navigate, + Route, + Routes, + Outlet, + + // Router creation + RouterWrapper, + + // Utilities + matchPath, +}; diff --git a/packages/ra-router-react-router/tsconfig.json b/packages/ra-router-react-router/tsconfig.json new file mode 100644 index 00000000000..36df4011939 --- /dev/null +++ b/packages/ra-router-react-router/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "src", + "allowJs": false, + "strictNullChecks": true + }, + "exclude": ["**/*.spec.ts", "**/*.spec.tsx"], + "include": ["src"] +} diff --git a/packages/ra-ui-materialui/package.json b/packages/ra-ui-materialui/package.json index 51be0e3df4e..7bba31365b2 100644 --- a/packages/ra-ui-materialui/package.json +++ b/packages/ra-ui-materialui/package.json @@ -49,7 +49,6 @@ "react-hook-form": "^7.72.0", "react-is": "^18.2.0 || ^19.0.0", "react-router": "^6.28.1", - "react-router-dom": "^6.28.1", "typescript": "^5.1.3", "zshy": "^0.5.0" }, @@ -64,9 +63,7 @@ "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0", "react-hook-form": "*", - "react-is": "^18.0.0 || ^19.0.0", - "react-router": "^6.28.1 || ^7.1.1", - "react-router-dom": "^6.28.1 || ^7.1.1" + "react-is": "^18.0.0 || ^19.0.0" }, "dependencies": { "autosuggest-highlight": "^3.1.1", diff --git a/packages/ra-ui-materialui/src/detail/Show.spec.tsx b/packages/ra-ui-materialui/src/detail/Show.spec.tsx index 66315048b82..05b8e57a2f7 100644 --- a/packages/ra-ui-materialui/src/detail/Show.spec.tsx +++ b/packages/ra-ui-materialui/src/detail/Show.spec.tsx @@ -12,7 +12,7 @@ import { import polyglotI18nProvider from 'ra-i18n-polyglot'; import englishMessages from 'ra-language-english'; -import { Route, Routes } from 'react-router-dom'; +import { Route, Routes } from 'react-router'; import { fireEvent, render, screen, waitFor } from '@testing-library/react'; import { AdminContext } from '../AdminContext'; diff --git a/packages/ra-ui-materialui/src/layout/Menu.stories.tsx b/packages/ra-ui-materialui/src/layout/Menu.stories.tsx index 1ac8c616b29..32f5f946237 100644 --- a/packages/ra-ui-materialui/src/layout/Menu.stories.tsx +++ b/packages/ra-ui-materialui/src/layout/Menu.stories.tsx @@ -25,7 +25,7 @@ import Inventory from '@mui/icons-material/Inventory'; import ExpandLess from '@mui/icons-material/ExpandLess'; import ExpandMore from '@mui/icons-material/ExpandMore'; import QrCode from '@mui/icons-material/QrCode'; -import { Route } from 'react-router-dom'; +import { Route } from 'react-router'; import { Layout, Menu, MenuItemLinkClasses, Title } from '.'; diff --git a/packages/ra-ui-materialui/src/list/List.stories.tsx b/packages/ra-ui-materialui/src/list/List.stories.tsx index f77547522db..cd41244ff56 100644 --- a/packages/ra-ui-materialui/src/list/List.stories.tsx +++ b/packages/ra-ui-materialui/src/list/List.stories.tsx @@ -2,6 +2,7 @@ import * as React from 'react'; import { Admin, AutocompleteInput, CardContentInner } from 'react-admin'; import { CustomRoutes, + LinkBase as Link, Resource, useListContext, TestMemoryRouter, @@ -27,7 +28,6 @@ import { ListActions } from './ListActions'; import { DataTable } from './datatable'; import { SearchInput, TextInput } from '../input'; import { Route } from 'react-router'; -import { Link } from 'react-router-dom'; import { BulkDeleteButton, ListButton, SelectAllButton } from '../button'; import { ShowGuesser } from '../detail'; import TopToolbar from '../layout/TopToolbar'; diff --git a/packages/react-admin/package.json b/packages/react-admin/package.json index 4c0fcf799a0..bd883d63506 100644 --- a/packages/react-admin/package.json +++ b/packages/react-admin/package.json @@ -32,7 +32,6 @@ "expect": "^27.4.6", "ra-data-fakerest": "^5.14.7", "react-router": "^6.28.1", - "react-router-dom": "^6.28.1", "typescript": "^5.1.3", "zshy": "^0.5.0" }, @@ -49,10 +48,9 @@ "ra-core": "^5.14.7", "ra-i18n-polyglot": "^5.14.7", "ra-language-english": "^5.14.7", + "ra-router-react-router": "^5.14.7", "ra-ui-materialui": "^5.14.7", - "react-hook-form": "^7.72.0", - "react-router": "^6.28.1 || ^7.1.1", - "react-router-dom": "^6.28.1 || ^7.1.1" + "react-hook-form": "^7.72.0" }, "gitHead": "19dcb264898c8e01c408eb66ce02c50b67c851ab", "exports": { diff --git a/packages/react-admin/src/Admin.stories.tsx b/packages/react-admin/src/Admin.stories.tsx index 4600df7db64..42c4c600223 100644 --- a/packages/react-admin/src/Admin.stories.tsx +++ b/packages/react-admin/src/Admin.stories.tsx @@ -1,6 +1,11 @@ import * as React from 'react'; -import { Routes, Route, Link } from 'react-router-dom'; -import { Resource, testDataProvider, TestMemoryRouter } from 'ra-core'; +import { Routes, Route } from 'react-router'; +import { + LinkBase as Link, + Resource, + testDataProvider, + TestMemoryRouter, +} from 'ra-core'; import type { AuthProvider } from 'ra-core'; import { Layout, diff --git a/packages/react-admin/src/Resource.stories.tsx b/packages/react-admin/src/Resource.stories.tsx index 68f4bc2f876..97bd5122502 100644 --- a/packages/react-admin/src/Resource.stories.tsx +++ b/packages/react-admin/src/Resource.stories.tsx @@ -1,5 +1,5 @@ import * as React from 'react'; -import { Route, Link, useParams } from 'react-router-dom'; +import { Route, useParams } from 'react-router'; import { Admin, Resource, @@ -7,6 +7,7 @@ import { List, EditGuesser, EditButton, + LinkBase as Link, useRecordContext, } from './'; import fakeRestDataProvider from 'ra-data-fakerest'; diff --git a/scripts/update-package-exports.ts b/scripts/update-package-exports.ts index 99a7b903fbd..7db27ef95a2 100644 --- a/scripts/update-package-exports.ts +++ b/scripts/update-package-exports.ts @@ -3,7 +3,12 @@ import fs from 'node:fs'; const packagesDir = path.join(__dirname, '..', 'packages'); const examplesDir = path.join(__dirname, '..', 'examples'); -const excludePackages = new Set(['create-react-admin']); +// ra-router-react-router-next is ESM-only (react-router v8 is ESM-only), so it +// must not get the dual import/require exports this script generates. +const excludePackages = new Set([ + 'create-react-admin', + 'ra-router-react-router-next', +]); const updatePackages = async () => { const packageNames = (await fs.promises.readdir(packagesDir)) diff --git a/yarn.lock b/yarn.lock index ee7293671ba..9a4bcc4ac8e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -9937,6 +9937,13 @@ __metadata: languageName: node linkType: hard +"cookie-es@npm:^3.1.1": + version: 3.1.1 + resolution: "cookie-es@npm:3.1.1" + checksum: 62cf0c325cc547b52477b351e9b5d068b3ffc74f7d143cdf7af854648dd1015fca3aed1498b355365e24b0746333f0b15688ed3a535abecc5ed0a046ae956a84 + languageName: node + linkType: hard + "cookie-signature@npm:~1.0.6": version: 1.0.7 resolution: "cookie-signature@npm:1.0.7" @@ -20388,6 +20395,7 @@ __metadata: jsonexport: "npm:^3.2.0" lodash: "npm:^4.17.21" query-string: "npm:^7.1.3" + ra-router-react-router: "npm:^5.14.7" react: "npm:^18.3.1" react-dom: "npm:^18.3.1" react-error-boundary: "npm:^4.0.13" @@ -20404,8 +20412,6 @@ __metadata: react: ^18.0.0 || ^19.0.0 react-dom: ^18.0.0 || ^19.0.0 react-hook-form: ^7.72.0 - react-router: ^6.28.1 || ^7.1.1 - react-router-dom: ^6.28.1 || ^7.1.1 languageName: unknown linkType: soft @@ -20616,7 +20622,6 @@ __metadata: react-dom: "npm:^18.3.1" react-dropzone: "npm:^14.2.3" react-router: "npm:^6.22.0" - react-router-dom: "npm:^6.22.0" typescript: "npm:^5.1.3" zshy: "npm:^0.5.0" peerDependencies: @@ -20624,6 +20629,7 @@ __metadata: "@mui/material": ^5.16.12 || ^6.0.0 || ^7.0.0 || ^9.0.0 react: ^18.0.0 || ^19.0.0 react-dom: ^18.0.0 || ^19.0.0 + react-router: ^6.28.1 || ^7.1.1 || ^8.0.0 languageName: unknown linkType: soft @@ -20662,6 +20668,39 @@ __metadata: languageName: unknown linkType: soft +"ra-router-react-router-next@workspace:packages/ra-router-react-router-next": + version: 0.0.0-use.local + resolution: "ra-router-react-router-next@workspace:packages/ra-router-react-router-next" + dependencies: + react: "npm:^19.2.7" + react-dom: "npm:^19.2.7" + react-router: "npm:^8.0.0" + typescript: "npm:^5.1.3" + zshy: "npm:^0.5.0" + peerDependencies: + ra-core: ^5.0.0 + react: ^19.2.7 + react-dom: ^19.2.7 + react-router: ^8.0.0 + languageName: unknown + linkType: soft + +"ra-router-react-router@npm:^5.14.7, ra-router-react-router@workspace:packages/ra-router-react-router": + version: 0.0.0-use.local + resolution: "ra-router-react-router@workspace:packages/ra-router-react-router" + dependencies: + react-router: "npm:^6.28.1" + react-router-dom: "npm:^6.28.1" + typescript: "npm:^5.1.3" + zshy: "npm:^0.5.0" + peerDependencies: + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + react-router: ^6.28.1 || ^7.1.1 + react-router-dom: ^6.28.1 || ^7.1.1 + languageName: unknown + linkType: soft + "ra-router-tanstack@workspace:packages/ra-router-tanstack": version: 0.0.0-use.local resolution: "ra-router-tanstack@workspace:packages/ra-router-tanstack" @@ -20720,7 +20759,6 @@ __metadata: react-hotkeys-hook: "npm:^5.1.0" react-is: "npm:^18.2.0 || ^19.0.0" react-router: "npm:^6.28.1" - react-router-dom: "npm:^6.28.1" react-transition-group: "npm:^4.4.5" typescript: "npm:^5.1.3" zshy: "npm:^0.5.0" @@ -20736,8 +20774,6 @@ __metadata: react-dom: ^18.0.0 || ^19.0.0 react-hook-form: "*" react-is: ^18.0.0 || ^19.0.0 - react-router: ^6.28.1 || ^7.1.1 - react-router-dom: ^6.28.1 || ^7.1.1 languageName: unknown linkType: soft @@ -20919,10 +20955,10 @@ __metadata: ra-data-fakerest: "npm:^5.14.7" ra-i18n-polyglot: "npm:^5.14.7" ra-language-english: "npm:^5.14.7" + ra-router-react-router: "npm:^5.14.7" ra-ui-materialui: "npm:^5.14.7" react-hook-form: "npm:^7.72.0" react-router: "npm:^6.28.1" - react-router-dom: "npm:^6.28.1" typescript: "npm:^5.1.3" zshy: "npm:^0.5.0" peerDependencies: @@ -20992,6 +21028,17 @@ __metadata: languageName: node linkType: hard +"react-dom@npm:^19.2.7": + version: 19.2.7 + resolution: "react-dom@npm:19.2.7" + dependencies: + scheduler: "npm:^0.27.0" + peerDependencies: + react: ^19.2.7 + checksum: 970ff600f6e80d47d39e2f226f12f226173b3cba3382efc97c5f0cd663de9af38c7a4c11c213fb936094faeac83060d660247accaa96b752180d5b951b9cfecb + languageName: node + linkType: hard + "react-dropzone@npm:^14.2.3": version: 14.2.3 resolution: "react-dropzone@npm:14.2.3" @@ -21146,7 +21193,7 @@ __metadata: languageName: node linkType: hard -"react-router-dom@npm:^6.22.0, react-router-dom@npm:^6.28.1": +"react-router-dom@npm:^6.28.1": version: 6.30.4 resolution: "react-router-dom@npm:6.30.4" dependencies: @@ -21198,6 +21245,21 @@ __metadata: languageName: node linkType: hard +"react-router@npm:^8.0.0": + version: 8.0.1 + resolution: "react-router@npm:8.0.1" + dependencies: + cookie-es: "npm:^3.1.1" + peerDependencies: + react: ">=19.2.7" + react-dom: ">=19.2.7" + peerDependenciesMeta: + react-dom: + optional: true + checksum: 7ffcac5caf209b6988a0e045fd044f5f8e91e7f677475f707becd514f316f6c2d556f90e371c5a41f6033da891a2d97370b1c5d443ba96991ab0f4f71252c8ba + languageName: node + linkType: hard + "react-simple-animate@npm:^3.3.12, react-simple-animate@npm:^3.5.3": version: 3.5.3 resolution: "react-simple-animate@npm:3.5.3" @@ -21238,6 +21300,13 @@ __metadata: languageName: node linkType: hard +"react@npm:^19.2.7": + version: 19.2.7 + resolution: "react@npm:19.2.7" + checksum: 0bd0e2f1bbd4ba97561c6597bf8a5fec05e6476fe61e165c1065598d16668efc6715205599c94d3ddd49d36cb0f21cbf1b9bcc18ee840b805ce222c3e8d558ac + languageName: node + linkType: hard + "read-cmd-shim@npm:4.0.0": version: 4.0.0 resolution: "read-cmd-shim@npm:4.0.0" @@ -22311,6 +22380,13 @@ __metadata: languageName: node linkType: hard +"scheduler@npm:^0.27.0": + version: 0.27.0 + resolution: "scheduler@npm:0.27.0" + checksum: 4f03048cb05a3c8fddc45813052251eca00688f413a3cee236d984a161da28db28ba71bd11e7a3dd02f7af84ab28d39fb311431d3b3772fed557945beb00c452 + languageName: node + linkType: hard + "schema-utils@npm:^3.1.1": version: 3.3.0 resolution: "schema-utils@npm:3.3.0"