From 9ef5fdccc5d5aab88845dfe8993b36e2e7508f0f Mon Sep 17 00:00:00 2001 From: Shaoyu Meng Date: Mon, 22 Jun 2026 15:38:04 -0700 Subject: [PATCH 01/56] support react-router v8 --- packages/ra-core/package.json | 3 +- .../ra-core/src/auth/Authenticated.spec.tsx | 2 +- .../src/auth/useAuthenticated.spec.tsx | 2 +- .../src/auth/useHandleAuthCallback.spec.tsx | 2 +- packages/ra-core/src/auth/useLogin.spec.tsx | 2 +- packages/ra-core/src/auth/useLogout.spec.tsx | 2 +- .../src/auth/useLogoutIfAccessDenied.spec.tsx | 2 +- .../create/useCreateController.spec.tsx | 2 +- .../src/controller/usePrevNextController.ts | 7 +- .../ra-core/src/core/CoreAdminContext.tsx | 2 +- .../ra-core/src/core/CoreAdminRoutes.spec.tsx | 2 +- ...eConfigureAdminRouterFromChildren.spec.tsx | 2 +- .../src/dataProvider/useGetRecordId.spec.tsx | 2 +- packages/ra-core/src/form/Form.stories.tsx | 18 +- .../form/useWarnWhenUnsavedChanges.spec.tsx | 2 +- .../ra-core/src/routing/CompatHashRouter.tsx | 180 ++++++++++++++++++ packages/ra-core/src/routing/CompatLink.tsx | 99 ++++++++++ .../ra-core/src/routing/TestMemoryRouter.tsx | 7 +- .../routing/adapters/reactRouterProvider.tsx | 69 ++++--- .../src/routing/useCreatePath.stories.tsx | 2 +- .../ra-core/src/routing/useRedirect.spec.tsx | 2 +- .../src/routing/useRedirect.stories.tsx | 9 +- .../ra-core/src/routing/useScrollToTop.tsx | 2 +- .../src/RichTextInput.stories.tsx | 2 +- packages/ra-no-code/package.json | 1 - .../src/ui/ImportResourceDialog.tsx | 2 +- .../ra-no-code/src/ui/ResourceMenuItem.tsx | 11 +- packages/ra-ui-materialui/package.json | 3 +- .../ra-ui-materialui/src/detail/Show.spec.tsx | 2 +- .../src/layout/Menu.stories.tsx | 2 +- .../src/list/List.stories.tsx | 2 +- packages/react-admin/package.json | 3 +- packages/react-admin/src/Admin.stories.tsx | 9 +- packages/react-admin/src/Resource.stories.tsx | 3 +- yarn.lock | 10 +- 35 files changed, 380 insertions(+), 92 deletions(-) create mode 100644 packages/ra-core/src/routing/CompatHashRouter.tsx create mode 100644 packages/ra-core/src/routing/CompatLink.tsx diff --git a/packages/ra-core/package.json b/packages/ra-core/package.json index 9d5f2d31be9..e982cb5dc27 100644 --- a/packages/ra-core/package.json +++ b/packages/ra-core/package.json @@ -66,8 +66,7 @@ "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-router": "^6.28.1 || ^7.1.1 || ^8.0.0" }, "dependencies": { "date-fns": "^3.6.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..4f7e611e720 100644 --- a/packages/ra-core/src/core/CoreAdminContext.tsx +++ b/packages/ra-core/src/core/CoreAdminContext.tsx @@ -174,7 +174,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..5d869fa60a9 100644 --- a/packages/ra-core/src/form/Form.stories.tsx +++ b/packages/ra-core/src/form/Form.stories.tsx @@ -11,14 +11,7 @@ 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 { CoreAdminContext } from '../core'; import { @@ -32,7 +25,8 @@ 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 { CompatHashRouter } from '../routing/CompatHashRouter'; import { useNotificationContext } from '../notification'; export default { @@ -409,16 +403,14 @@ export const InNonDataRouter = ({ }: { i18nProvider?: I18nProvider; }) => ( - + Go to form} /> } /> - + ); const PostEditWithDelete = ({ 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/routing/CompatHashRouter.tsx b/packages/ra-core/src/routing/CompatHashRouter.tsx new file mode 100644 index 00000000000..a5fcddbde76 --- /dev/null +++ b/packages/ra-core/src/routing/CompatHashRouter.tsx @@ -0,0 +1,180 @@ +import * as React from 'react'; +import { useLayoutEffect, useRef, useState } from 'react'; +import { createPath, NavigationType, parsePath, Router } from 'react-router'; +import type { Location, Navigator, Path, To } from 'react-router'; +import type { RouterWrapperProps } from './RouterProvider'; + +type HashHistory = Navigator & { + readonly action: NavigationType; + readonly location: Location; + listen( + listener: (update: { + action: NavigationType; + location: Location; + }) => void + ): () => void; +}; + +const createKey = () => Math.random().toString(36).substring(2, 10); + +/** + * Minimal hash history for the v6 fallback router. react-router v6 does not + * export `createHashRouter` (it lived in `react-router-dom`) nor the low-level + * `createHashHistory`, so we build a hash history on top of the browser History + * API and feed it to the low-level `` component (available on every + * version). It is a faithful port of react-router's own hash + * history. + */ +const createHashHistory = (): HashHistory => { + const globalHistory = window.history; + let action: NavigationType = NavigationType.Pop; + let index: number; + let location: Location; + const listeners = new Set< + (update: { action: NavigationType; location: Location }) => void + >(); + + const getIndexAndLocation = (): [number, Location] => { + const { + pathname = '/', + search = '', + hash = '', + } = parsePath(window.location.hash.substring(1)); + const historyState = globalHistory.state || {}; + return [ + historyState.idx, + { + pathname, + search, + hash, + state: historyState.usr ?? null, + key: historyState.key ?? 'default', + }, + ]; + }; + + [index, location] = getIndexAndLocation(); + if (index == null) { + index = 0; + globalHistory.replaceState({ ...globalHistory.state, idx: index }, ''); + } + + const getNextLocation = (to: To, state: any = null): Location => { + const parsed = typeof to === 'string' ? parsePath(to) : to; + return { + pathname: location.pathname, + search: '', + hash: '', + ...parsed, + state, + key: createKey(), + } as Location; + }; + + const getHistoryStateAndUrl = ( + nextLocation: Location, + nextIndex: number + ): [any, string] => [ + { usr: nextLocation.state, key: nextLocation.key, idx: nextIndex }, + '#' + createPath(nextLocation), + ]; + + const notify = () => { + listeners.forEach(listener => listener({ action, location })); + }; + + const handlePop = () => { + action = NavigationType.Pop; + const [nextIndex, nextLocation] = getIndexAndLocation(); + index = nextIndex ?? 0; + location = nextLocation; + notify(); + }; + + window.addEventListener('popstate', handlePop); + window.addEventListener('hashchange', () => { + const [, nextLocation] = getIndexAndLocation(); + // popstate already handles back/forward; only react to manual hash edits. + if (createPath(nextLocation) !== createPath(location)) { + handlePop(); + } + }); + + return { + get action() { + return action; + }, + get location() { + return location; + }, + createHref: (to: To) => + '#' + (typeof to === 'string' ? to : createPath(to)), + encodeLocation: (to: To): Path => { + const path = typeof to === 'string' ? parsePath(to) : to; + return { + pathname: path.pathname || '', + search: path.search || '', + hash: path.hash || '', + }; + }, + push: (to: To, state?: any) => { + action = NavigationType.Push; + location = getNextLocation(to, state); + index += 1; + const [historyState, url] = getHistoryStateAndUrl(location, index); + try { + globalHistory.pushState(historyState, '', url); + } catch { + // iOS limits history.pushState calls; fall back to a hard reload. + window.location.assign(url); + } + notify(); + }, + replace: (to: To, state?: any) => { + action = NavigationType.Replace; + location = getNextLocation(to, state); + const [historyState, url] = getHistoryStateAndUrl(location, index); + globalHistory.replaceState(historyState, '', url); + notify(); + }, + go: (delta: number) => globalHistory.go(delta), + listen: listener => { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; + }, + }; +}; + +/** + * Non-data hash router for the v6 fallback. react-router v6 does not export + * `createHashRouter`/`HashRouter` from the `react-router` package, so this builds + * a hash router on the low-level `` component. + */ +export const CompatHashRouter = ({ + basename, + children, +}: RouterWrapperProps) => { + const historyRef = useRef(undefined); + if (historyRef.current == null) { + historyRef.current = createHashHistory(); + } + const history = historyRef.current!; + const [state, setState] = useState({ + action: history.action, + location: history.location, + }); + useLayoutEffect(() => history.listen(setState), [history]); + + return ( + + {children} + + ); +}; diff --git a/packages/ra-core/src/routing/CompatLink.tsx b/packages/ra-core/src/routing/CompatLink.tsx new file mode 100644 index 00000000000..7fc086eb890 --- /dev/null +++ b/packages/ra-core/src/routing/CompatLink.tsx @@ -0,0 +1,99 @@ +import * as React from 'react'; +import { forwardRef } from 'react'; +import { + createPath, + useHref, + useLocation, + useNavigate, + useResolvedPath, +} from 'react-router'; +import type { To } from 'react-router'; +import type { RouterLinkProps } from './RouterProvider'; + +const isModifiedEvent = (event: React.MouseEvent) => + !!(event.metaKey || event.altKey || event.ctrlKey || event.shiftKey); + +const shouldProcessLinkClick = (event: React.MouseEvent, target?: string) => + event.button === 0 && // ignore everything but left clicks + (!target || target === '_self') && // let the browser handle "target=_blank" etc. + !isModifiedEvent(event); // ignore clicks with modifier keys + +type CompatLinkProps = RouterLinkProps & { + target?: string; + relative?: 'route' | 'path'; + reloadDocument?: boolean; + preventScrollReset?: boolean; + viewTransition?: boolean; + onClick?: React.MouseEventHandler; +}; + +/** + * Minimal `` reimplementation for react-router v6, where `Link` is not + * exported from the `react-router` package (it lived only in `react-router-dom`). + * Mirrors react-router's own `Link` using only primitives available on every + * supported version (`useHref`, `useResolvedPath`, `useNavigate`, `createPath`). + * + * Used as the v6 fallback by the default react-router adapter, which prefers + * react-router's native `Link` when it exists (v7/v8). + */ +export const CompatLink = forwardRef( + function CompatLink( + { + children, + onClick, + relative, + reloadDocument, + replace, + state, + target, + to, + preventScrollReset, + viewTransition, + ...rest + }, + ref + ) { + const href = useHref(to as To, { relative }); + const navigate = useNavigate(); + const location = useLocation(); + const path = useResolvedPath(to as To, { relative }); + + const handleClick = ( + event: React.MouseEvent + ) => { + onClick?.(event); + if ( + !event.defaultPrevented && + !reloadDocument && + shouldProcessLinkClick(event, target) + ) { + event.preventDefault(); + // If the URL hasn't changed, a regular will do a replace + // instead of a push, so we do the same here unless overridden. + const replaceProp = + replace !== undefined + ? replace + : createPath(location) === createPath(path); + navigate(to as To, { + replace: replaceProp, + state, + preventScrollReset, + relative, + viewTransition, + }); + } + }; + + return ( + + {children} + + ); + } +); 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 index 29f3da7c482..bdf4a80cdcc 100644 --- a/packages/ra-core/src/routing/adapters/reactRouterProvider.tsx +++ b/packages/ra-core/src/routing/adapters/reactRouterProvider.tsx @@ -1,5 +1,6 @@ import * as React from 'react'; -import { useContext, useEffect, useRef, ReactNode } from 'react'; +import { useContext, useEffect, useRef } from 'react'; +import * as ReactRouter from 'react-router'; import { useNavigate as useReactRouterNavigate, useLocation, @@ -7,28 +8,36 @@ import { useBlocker, useMatch, useInRouterContext, - Link, Navigate, Route, Routes, Outlet, matchPath, - createHashRouter, RouterProvider as ReactRouterProvider, UNSAFE_DataRouterContext, UNSAFE_DataRouterStateContext, type FutureConfig, -} from 'react-router-dom'; +} from 'react-router'; +import { CompatLink } from '../CompatLink'; +import { CompatHashRouter } from '../CompatHashRouter'; import type { RouterProvider, RouterWrapperProps, RouterNavigateFunction, + RouterLinkProps, } from '../RouterProvider'; const routerProviderFuture: Partial< Pick > = { v7_startTransition: false, v7_relativeSplatPath: false }; +// Allow conditionally check whether `Link` and `createHashRouter` are exported during runtime. +// Fallback to `CompatLink` and `CompatHashRouter` if they are not available. +const reactRouter = ReactRouter as unknown as Record; + +const Link = (reactRouter.Link ?? + CompatLink) as React.ComponentType; + /** * Hook to check if navigation blocking is supported. * In react-router, blocking requires a data router. @@ -68,26 +77,34 @@ const useNavigate = (): 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 ( - - ); +const InternalRouter = ({ basename, children }: RouterWrapperProps) => { + const createHashRouter = reactRouter.createHashRouter as + | ((routes: any[], opts?: any) => any) + | undefined; + + if (createHashRouter) { + const router = createHashRouter( + [{ path: '*', element: <>{children} }], + { + basename, + future: { + v7_fetcherPersist: false, + v7_normalizeFormMethod: false, + v7_partialHydration: false, + v7_relativeSplatPath: false, + v7_skipActionErrorRevalidation: false, + }, + } + ); + return ( + + ); + } + + return {children}; }; /** @@ -105,7 +122,7 @@ const RouterWrapper = ({ basename, children }: RouterWrapperProps) => { }; /** - * Default router provider using react-router-dom. + * Default router provider using react-router. * This provider is used by default when no custom routerProvider is provided to . */ export const reactRouterProvider: RouterProvider = { @@ -121,7 +138,7 @@ export const reactRouterProvider: RouterProvider = { // Components Link, Navigate, - Route, + Route: Route as RouterProvider['Route'], Routes, Outlet, 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..4af76560bd7 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" }, 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-ui-materialui/package.json b/packages/ra-ui-materialui/package.json index 51be0e3df4e..bb1f17d0537 100644 --- a/packages/ra-ui-materialui/package.json +++ b/packages/ra-ui-materialui/package.json @@ -65,8 +65,7 @@ "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-router": "^6.28.1 || ^7.1.1 || ^8.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..ff551828891 100644 --- a/packages/react-admin/package.json +++ b/packages/react-admin/package.json @@ -51,8 +51,7 @@ "ra-language-english": "^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-router": "^6.28.1 || ^7.1.1 || ^8.0.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/yarn.lock b/yarn.lock index ee7293671ba..50c5b66f12b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -20404,8 +20404,11 @@ __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: ^6.28.1 || ^7.1.1 || ^8.0.0 react-router-dom: ^6.28.1 || ^7.1.1 + peerDependenciesMeta: + react-router-dom: + optional: true languageName: unknown linkType: soft @@ -20736,8 +20739,11 @@ __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: ^6.28.1 || ^7.1.1 || ^8.0.0 react-router-dom: ^6.28.1 || ^7.1.1 + peerDependenciesMeta: + react-router-dom: + optional: true languageName: unknown linkType: soft From 6c3b1895f7dc68606f835299fb0a0818c9abfea6 Mon Sep 17 00:00:00 2001 From: Shaoyu Meng Date: Mon, 22 Jun 2026 16:11:52 -0700 Subject: [PATCH 02/56] update comments --- .../ra-core/src/routing/CompatHashRouter.tsx | 21 +++++++++++-------- packages/ra-core/src/routing/CompatLink.tsx | 3 ++- 2 files changed, 14 insertions(+), 10 deletions(-) diff --git a/packages/ra-core/src/routing/CompatHashRouter.tsx b/packages/ra-core/src/routing/CompatHashRouter.tsx index a5fcddbde76..88c64d964b8 100644 --- a/packages/ra-core/src/routing/CompatHashRouter.tsx +++ b/packages/ra-core/src/routing/CompatHashRouter.tsx @@ -18,12 +18,12 @@ type HashHistory = Navigator & { const createKey = () => Math.random().toString(36).substring(2, 10); /** - * Minimal hash history for the v6 fallback router. react-router v6 does not - * export `createHashRouter` (it lived in `react-router-dom`) nor the low-level - * `createHashHistory`, so we build a hash history on top of the browser History - * API and feed it to the low-level `` component (available on every - * version). It is a faithful port of react-router's own hash - * history. + * Minimal hash history reimplementation react-router v6, where + * `createHashRouter` is not exported from the `react-router` package (it + * lived only in `react-router-dom`), nor the low-level `createHashHistory`. + * Mirrors `createHashHistory` and builds upon the browser History API. + * It feeds into the low-level `` component which is available + * on every supported version. */ const createHashHistory = (): HashHistory => { const globalHistory = window.history; @@ -148,9 +148,12 @@ const createHashHistory = (): HashHistory => { }; /** - * Non-data hash router for the v6 fallback. react-router v6 does not export - * `createHashRouter`/`HashRouter` from the `react-router` package, so this builds - * a hash router on the low-level `` component. + * Minimal non-data `` reimplementation for the react-router v6, where `HashRouter` + * is not exported from the `react-router` package (it lived only in `react-router-dom`). + * + * Used as the v6 fallback by the default `InternalRouter`, which prefers react-router's native + * `createHashRouter` when it exists (v7/v8). v6 users can always override this by wrapping + * the react element tree with `react-router-dom`'s `` component. */ export const CompatHashRouter = ({ basename, diff --git a/packages/ra-core/src/routing/CompatLink.tsx b/packages/ra-core/src/routing/CompatLink.tsx index 7fc086eb890..f68cdfa83ea 100644 --- a/packages/ra-core/src/routing/CompatLink.tsx +++ b/packages/ra-core/src/routing/CompatLink.tsx @@ -34,7 +34,8 @@ type CompatLinkProps = RouterLinkProps & { * supported version (`useHref`, `useResolvedPath`, `useNavigate`, `createPath`). * * Used as the v6 fallback by the default react-router adapter, which prefers - * react-router's native `Link` when it exists (v7/v8). + * react-router's native `Link` when it exists (v7/v8). v6 users can always override + * this with the `react-router-dom`'s `` component. */ export const CompatLink = forwardRef( function CompatLink( From 352bcd50286ce4899318b96e1e82cee4927c34fd Mon Sep 17 00:00:00 2001 From: Shaoyu Meng Date: Mon, 22 Jun 2026 16:17:02 -0700 Subject: [PATCH 03/56] update lock file --- yarn.lock | 11 +---------- 1 file changed, 1 insertion(+), 10 deletions(-) diff --git a/yarn.lock b/yarn.lock index 50c5b66f12b..4c6d1f4819c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -20405,10 +20405,6 @@ __metadata: react-dom: ^18.0.0 || ^19.0.0 react-hook-form: ^7.72.0 react-router: ^6.28.1 || ^7.1.1 || ^8.0.0 - react-router-dom: ^6.28.1 || ^7.1.1 - peerDependenciesMeta: - react-router-dom: - optional: true languageName: unknown linkType: soft @@ -20619,7 +20615,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: @@ -20740,10 +20735,6 @@ __metadata: react-hook-form: "*" react-is: ^18.0.0 || ^19.0.0 react-router: ^6.28.1 || ^7.1.1 || ^8.0.0 - react-router-dom: ^6.28.1 || ^7.1.1 - peerDependenciesMeta: - react-router-dom: - optional: true languageName: unknown linkType: soft @@ -21152,7 +21143,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: From 0df7e82af61deae29d9ccf777913042e18ceec7a Mon Sep 17 00:00:00 2001 From: Shaoyu Meng Date: Mon, 22 Jun 2026 19:33:01 -0700 Subject: [PATCH 04/56] fix missing error boundary tests --- .../src/routing/CompatHashRouter.spec.tsx | 63 ++++++++++++++++ .../ra-core/src/routing/CompatHashRouter.tsx | 75 ++++++++++++++++++- 2 files changed, 137 insertions(+), 1 deletion(-) create mode 100644 packages/ra-core/src/routing/CompatHashRouter.spec.tsx diff --git a/packages/ra-core/src/routing/CompatHashRouter.spec.tsx b/packages/ra-core/src/routing/CompatHashRouter.spec.tsx new file mode 100644 index 00000000000..0411582a3aa --- /dev/null +++ b/packages/ra-core/src/routing/CompatHashRouter.spec.tsx @@ -0,0 +1,63 @@ +import * as React from 'react'; +import { useEffect } from 'react'; +import expect from 'expect'; +import { fireEvent, render, screen } from '@testing-library/react'; +import { useLocation, useNavigate } from 'react-router'; + +import { CompatHashRouter } from './CompatHashRouter'; + +describe('CompatHashRouter', () => { + it('should render its children', async () => { + render( + + Hello + + ); + await screen.findByText('Hello'); + }); + + it('should display the error message when a child throws during render', async () => { + const consoleError = jest + .spyOn(console, 'error') + .mockImplementation(() => {}); + const Throw = () => { + throw new Error('boom'); + }; + render( + + + + ); + await screen.findByText('boom'); + await screen.findByText('Unexpected Application Error!'); + consoleError.mockRestore(); + }); + + it('should not remount its children on navigation', async () => { + let mountCount = 0; + const Child = () => { + const navigate = useNavigate(); + const location = useLocation(); + useEffect(() => { + mountCount += 1; + }, []); + return ( + + ); + }; + render( + + + + ); + const button = await screen.findByText('/'); + expect(mountCount).toBe(1); + fireEvent.click(button); + // Navigation happened... + await screen.findByText('/other'); + // ...but the children were not remounted. + expect(mountCount).toBe(1); + }); +}); diff --git a/packages/ra-core/src/routing/CompatHashRouter.tsx b/packages/ra-core/src/routing/CompatHashRouter.tsx index 88c64d964b8..bd6ebafd0a5 100644 --- a/packages/ra-core/src/routing/CompatHashRouter.tsx +++ b/packages/ra-core/src/routing/CompatHashRouter.tsx @@ -147,6 +147,77 @@ const createHashHistory = (): HashHistory => { }; }; +const errorPreStyle: React.CSSProperties = { + padding: '0.5rem', + backgroundColor: 'rgba(200, 200, 200, 0.5)', +}; + +interface RouterErrorBoundaryProps { + children: React.ReactNode; + location: Location; +} + +interface RouterErrorBoundaryState { + error: Error | null; + location: Location; +} + +/** + * Mirrors react-router's default error boundary, which data routers provide out of the box + * through `RouterProvider`. The low-level `` used by `CompatHashRouter` has no such + * boundary, so without this a render error thrown by a descendant would propagate uncaught + * instead of being displayed. The error is cleared on navigation (like react-router's data + * router), without remounting the children while no error is displayed. + */ +class RouterErrorBoundary extends React.Component< + RouterErrorBoundaryProps, + RouterErrorBoundaryState +> { + state: RouterErrorBoundaryState = { + error: null, + location: this.props.location, + }; + + static getDerivedStateFromError(error: Error) { + return { error }; + } + + static getDerivedStateFromProps( + props: RouterErrorBoundaryProps, + state: RouterErrorBoundaryState + ): Partial { + // Clear a displayed error once the user navigates away from it. + if (state.error != null && props.location !== state.location) { + return { error: null, location: props.location }; + } + return { location: props.location }; + } + + componentDidCatch(error: Error, info: React.ErrorInfo) { + console.error( + 'React Router caught the following error during render', + error, + info + ); + } + + render() { + const { error } = this.state; + if (error == null) { + return this.props.children; + } + const message = error instanceof Error ? error.message : String(error); + const stack = error instanceof Error ? error.stack : null; + return ( + <> +

    Unexpected Application Error!

    +

    {message}

    + {stack ?
    {stack}
    : null} + + ); + } +} + /** * Minimal non-data `` reimplementation for the react-router v6, where `HashRouter` * is not exported from the `react-router` package (it lived only in `react-router-dom`). @@ -177,7 +248,9 @@ export const CompatHashRouter = ({ navigationType={state.action} navigator={history} > - {children} + + {children} +
    ); }; From f515372d8628549ab03d50146298c4cc582577b5 Mon Sep 17 00:00:00 2001 From: Shaoyu Meng Date: Tue, 23 Jun 2026 07:13:46 -0700 Subject: [PATCH 05/56] feat: Add ra-router-react-router-v8 adapter package React Router v8 merged react-router-dom into react-router and requires React 19. Rather than adding a compatibility layer and partial reimplementation inside ra-core (per review feedback on #11289), v8 support ships as a standalone, opt-in adapter package mirroring ra-router-tanstack. The adapter is a thin pass-through over react-router v8's native API: the obsolete v6/v7 `future` flags are dropped (now defaults in v8) and imports come from `react-router` instead of `react-router-dom`. ra-core keeps its built-in react-router v6/v7 adapter as the default, so existing apps are unaffected. The package is ESM-only because react-router v8 is ESM-only. Co-Authored-By: Claude Opus 4.8 (1M context) --- Makefile | 6 +- docs/ReactRouterV8.md | 57 +++ docs/Routing.md | 2 +- packages/ra-router-react-router-v8/README.md | 46 +++ .../ra-router-react-router-v8/package.json | 45 +++ .../ra-router-react-router-v8/src/index.ts | 1 + .../src/reactRouterV8Provider.stories.tsx | 341 ++++++++++++++++++ .../src/reactRouterV8Provider.tsx | 125 +++++++ .../ra-router-react-router-v8/tsconfig.json | 13 + yarn.lock | 38 ++ 10 files changed, 672 insertions(+), 2 deletions(-) create mode 100644 docs/ReactRouterV8.md create mode 100644 packages/ra-router-react-router-v8/README.md create mode 100644 packages/ra-router-react-router-v8/package.json create mode 100644 packages/ra-router-react-router-v8/src/index.ts create mode 100644 packages/ra-router-react-router-v8/src/reactRouterV8Provider.stories.tsx create mode 100644 packages/ra-router-react-router-v8/src/reactRouterV8Provider.tsx create mode 100644 packages/ra-router-react-router-v8/tsconfig.json diff --git a/Makefile b/Makefile index 2eecf2b0388..236ac24cc6e 100644 --- a/Makefile +++ b/Makefile @@ -56,6 +56,10 @@ build-ra-router-tanstack: @echo "Transpiling ra-router-tanstack files..."; @cd ./packages/ra-router-tanstack && yarn build +build-ra-router-react-router-v8: + @echo "Transpiling ra-router-react-router-v8 files..."; + @cd ./packages/ra-router-react-router-v8 && yarn build + build-ra-ui-materialui: @echo "Transpiling ra-ui-materialui files..."; @cd ./packages/ra-ui-materialui && yarn build @@ -129,7 +133,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-core build-ra-router-tanstack build-ra-router-react-router-v8 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/ReactRouterV8.md b/docs/ReactRouterV8.md new file mode 100644 index 00000000000..5a496334110 --- /dev/null +++ b/docs/ReactRouterV8.md @@ -0,0 +1,57 @@ +--- +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 its built-in router adapter. 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-v8`. + +Use this package when your application runs on React Router v8. + +## Installation + +```bash +npm install ra-router-react-router-v8 react-router@^8 +# or +yarn add ra-router-react-router-v8 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 `reactRouterV8Provider`: + +```tsx +import { Admin, Resource, ListGuesser } from 'react-admin'; +import { reactRouterV8Provider } from 'ra-router-react-router-v8'; +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 `reactRouterV8Provider` without an existing React Router, react-admin creates its own hash router automatically (URLs like `/#/posts`). This is the simplest setup and requires no additional configuration. + +## Embedded Mode + +When react-admin is rendered inside an existing React Router context, the provider detects it and uses that router instead of creating a new one, so react-admin integrates into a larger React Router v8 application. + +## When To Use This Package + +- Use the **built-in** react-router adapter (no extra package) if your app runs on React Router v6 or v7. +- Use **`ra-router-react-router-v8`** if your app runs on React Router v8 (and therefore React 19). diff --git a/docs/Routing.md b/docs/Routing.md index 2501a9b37a8..d8683407488 100644 --- a/docs/Routing.md +++ b/docs/Routing.md @@ -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) 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: diff --git a/packages/ra-router-react-router-v8/README.md b/packages/ra-router-react-router-v8/README.md new file mode 100644 index 00000000000..e2e507551ed --- /dev/null +++ b/packages/ra-router-react-router-v8/README.md @@ -0,0 +1,46 @@ +# ra-router-react-router-v8 + +[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. Use this package to +run react-admin on React Router v8. + +> **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-v8 react-router@^8 +# or +yarn add ra-router-react-router-v8 react-router@^8 +``` + +## Usage + +Pass the `reactRouterV8Provider` to the `routerProvider` prop of the `` component: + +```tsx +import { Admin, Resource } from 'react-admin'; +import { reactRouterV8Provider } from 'ra-router-react-router-v8'; +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 + +This package is licensed under the MIT License. diff --git a/packages/ra-router-react-router-v8/package.json b/packages/ra-router-react-router-v8/package.json new file mode 100644 index 00000000000..6732592cbbc --- /dev/null +++ b/packages/ra-router-react-router-v8/package.json @@ -0,0 +1,45 @@ +{ + "name": "ra-router-react-router-v8", + "version": "5.14.7", + "description": "React Router v8 provider for react-admin", + "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" + }, + "devDependencies": { + "ra-core": "^5.14.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-v8/src/index.ts b/packages/ra-router-react-router-v8/src/index.ts new file mode 100644 index 00000000000..0e71ff07fdc --- /dev/null +++ b/packages/ra-router-react-router-v8/src/index.ts @@ -0,0 +1 @@ +export * from './reactRouterV8Provider'; diff --git a/packages/ra-router-react-router-v8/src/reactRouterV8Provider.stories.tsx b/packages/ra-router-react-router-v8/src/reactRouterV8Provider.stories.tsx new file mode 100644 index 00000000000..7cf0241d804 --- /dev/null +++ b/packages/ra-router-react-router-v8/src/reactRouterV8Provider.stories.tsx @@ -0,0 +1,341 @@ +import * as React from 'react'; +import fakeDataProvider from 'ra-data-fakerest'; +import { + CoreAdmin, + Resource, + CustomRoutes, + ListBase, + ShowBase, + EditBase, + CreateBase, + useRecordContext, + useNavigate, + useLocation, + LinkBase, + useBlocker, + Form, + testUI, +} from 'ra-core'; +import { reactRouterV8Provider } from './reactRouterV8Provider'; + +const { useParams, useMatch, useInRouterContext, Route, Navigate } = + reactRouterV8Provider; +const { TextInput } = testUI; + +export default { + title: 'ra-routing-react-router-v8/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 LocationDisplay = () => { + const location = useLocation(); + return
    {location.pathname}
    ; +}; + +const Layout = ({ children }: { children?: React.ReactNode }) => ( +
    + + {children} +
    +); + +const PostList = () => { + const navigate = useNavigate(); + return ( + ( +
    +

    Posts

    +
      + {data?.map(record => ( +
    • + + {record.title} + +
    • + ))} +
    + +
    + )} + /> + ); +}; + +const PostShow = () => { + const navigate = useNavigate(); + const { id } = useParams<{ id: string }>(); + return ( + + navigate(`/posts/${id}`)} /> + + ); +}; + +const PostShowView = ({ onEdit }: { onEdit: () => void }) => { + const record = useRecordContext(); + if (!record) return null; + return ( +
    +

    {record.title}

    +

    {record.body}

    + + Back to list +
    + ); +}; + +const PostEdit = () => { + const { id } = useParams<{ id: string }>(); + return ( + +
    + + + +
    + ); +}; + +const PostCreate = () => ( + +
    + + + +
    +); + +/** + * BasicStandalone: react-admin runs on its own hash router created by the + * react-router v8 provider (no surrounding router). + */ +export const BasicStandalone = () => ( + + + +); + +/** + * MultipleResources: several resources sharing the v8 provider. + */ +const CommentList = () => ( + ( +
      + {data?.map(record =>
    • {record.body}
    • )} +
    + )} + /> +); + +export const MultipleResources = () => ( + + + + +); + +/** + * LinkComponent: navigation through LinkBase (which renders the provider Link). + */ +export const LinkComponent = () => ( + + + +); + +/** + * NavigateComponent: declarative redirect through the provider Navigate. + */ +const RedirectToPosts = () => ; + +export const NavigateComponent = () => ( + + + } /> + + + +); + +/** + * CustomRoutesSupport: a custom page rendered through CustomRoutes. + */ +const CustomPage = () => { + const location = useLocation(); + return ( +
    Custom page at {location.pathname}
    + ); +}; + +export const CustomRoutesSupport = () => ( + + + } /> + + + +); + +/** + * UseParamsTest: reads the record id from the URL params. + */ +const ParamsReader = () => { + const params = useParams(); + return
    {JSON.stringify(params)}
    ; +}; + +export const UseParamsTest = () => ( + + + } /> + + + +); + +/** + * UseMatchTest: matches the current location against a pattern. + */ +const MatchReader = () => { + const match = useMatch({ path: '/posts/:id/show' }); + return
    {JSON.stringify(match)}
    ; +}; + +export const UseMatchTest = () => ( + + + } /> + + + +); + +/** + * UseLocationTest: surfaces the current location. + */ +export const UseLocationTest = () => ( + + + +); + +/** + * RouterContextTest: confirms react-admin detects it is inside a router. + */ +const InRouterContextReader = () => { + const inContext = useInRouterContext(); + return
    {String(inContext)}
    ; +}; + +export const RouterContextTest = () => ( + + + } /> + + + +); + +/** + * UseBlockerTest: blocks navigation while a form is dirty. + */ +const BlockerForm = () => { + const [dirty, setDirty] = React.useState(false); + const blocker = useBlocker(dirty); + const navigate = useNavigate(); + return ( +
    + + + {blocker.state === 'blocked' ? ( +
    + + +
    + ) : null} +
    + ); +}; + +export const UseBlockerTest = () => ( + + + } /> + + + +); diff --git a/packages/ra-router-react-router-v8/src/reactRouterV8Provider.tsx b/packages/ra-router-react-router-v8/src/reactRouterV8Provider.tsx new file mode 100644 index 00000000000..880bfbeb63e --- /dev/null +++ b/packages/ra-router-react-router-v8/src/reactRouterV8Provider.tsx @@ -0,0 +1,125 @@ +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, +} from 'react-router'; +import type { + RouterProvider, + RouterWrapperProps, + RouterNavigateFunction, +} 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 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, + }); + 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. + */ +export const reactRouterV8Provider: 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-v8/tsconfig.json b/packages/ra-router-react-router-v8/tsconfig.json new file mode 100644 index 00000000000..af844224073 --- /dev/null +++ b/packages/ra-router-react-router-v8/tsconfig.json @@ -0,0 +1,13 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "src", + "allowJs": false, + "strictNullChecks": true, + "module": "esnext", + "moduleResolution": "bundler" + }, + "exclude": ["**/*.spec.ts", "**/*.spec.tsx"], + "include": ["src"] +} diff --git a/yarn.lock b/yarn.lock index ee7293671ba..cb9783d8146 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" @@ -20662,6 +20669,22 @@ __metadata: languageName: unknown linkType: soft +"ra-router-react-router-v8@workspace:packages/ra-router-react-router-v8": + version: 0.0.0-use.local + resolution: "ra-router-react-router-v8@workspace:packages/ra-router-react-router-v8" + dependencies: + ra-core: "npm:^5.14.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-tanstack@workspace:packages/ra-router-tanstack": version: 0.0.0-use.local resolution: "ra-router-tanstack@workspace:packages/ra-router-tanstack" @@ -21198,6 +21221,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" From d8b41fc68b74f3cef59c3955e70c46240bc24103 Mon Sep 17 00:00:00 2001 From: Shaoyu Meng Date: Tue, 23 Jun 2026 10:05:41 -0700 Subject: [PATCH 06/56] docs: Note deferred unit tests pending React 19 test lane Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/ra-router-react-router-v8/README.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/packages/ra-router-react-router-v8/README.md b/packages/ra-router-react-router-v8/README.md index e2e507551ed..0fed908a867 100644 --- a/packages/ra-router-react-router-v8/README.md +++ b/packages/ra-router-react-router-v8/README.md @@ -41,6 +41,14 @@ 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. +## Development + +This package ships with Storybook stories (`*.stories.tsx`) covering the adapter's +behavior. Unit tests are not included yet: React Router v8 requires React 19, while +react-admin's test suite currently runs on React 18, so the stories cannot be +exercised by the shared Jest run. Adding a React 19 test lane for this package is a +follow-up once the project's test environment supports React 19. + ## License This package is licensed under the MIT License. From eb6032e81080ab08947cd6a2d702768330d845bf Mon Sep 17 00:00:00 2001 From: Shaoyu Meng Date: Tue, 23 Jun 2026 12:43:09 -0700 Subject: [PATCH 07/56] refactor: Drop in-core react-router compat layer on react-router-v8-old After merging the ra-router-react-router-v8 package, the in-core react-router adapter no longer needs the compatibility shims: - reactRouterProvider imports everything from `react-router`, with only `Link` and `createHashRouter` from `react-router-dom` - Form.stories uses `HashRouter` from `react-router-dom` directly - Remove CompatHashRouter, CompatLink and their tests - Restore `react-router-dom` to the dependencies of ra-core, ra-ui-materialui and react-admin, and narrow the react-router range back to ^6.28.1 || ^7.1.1 (v8 is supported via the separate package) Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/ra-core/package.json | 14 +- packages/ra-core/src/form/Form.stories.tsx | 8 +- .../src/routing/CompatHashRouter.spec.tsx | 63 ----- .../ra-core/src/routing/CompatHashRouter.tsx | 256 ------------------ packages/ra-core/src/routing/CompatLink.tsx | 100 ------- .../routing/adapters/reactRouterProvider.tsx | 66 ++--- packages/ra-ui-materialui/package.json | 3 +- packages/react-admin/package.json | 3 +- yarn.lock | 6 +- 9 files changed, 42 insertions(+), 477 deletions(-) delete mode 100644 packages/ra-core/src/routing/CompatHashRouter.spec.tsx delete mode 100644 packages/ra-core/src/routing/CompatHashRouter.tsx delete mode 100644 packages/ra-core/src/routing/CompatLink.tsx diff --git a/packages/ra-core/package.json b/packages/ra-core/package.json index e982cb5dc27..3e495970212 100644 --- a/packages/ra-core/package.json +++ b/packages/ra-core/package.json @@ -15,14 +15,9 @@ "type": "module", "exports": { ".": { - "import": { - "types": "./dist/index.d.ts", - "default": "./dist/index.js" - }, - "require": { - "types": "./dist/index.d.cts", - "default": "./dist/index.cjs" - } + "types": "./dist/index.d.cts", + "import": "./dist/index.js", + "require": "./dist/index.cjs" } }, "sideEffects": false, @@ -66,7 +61,8 @@ "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 || ^8.0.0" + "react-router": "^6.28.1 || ^7.1.1", + "react-router-dom": "^6.28.1 || ^7.1.1" }, "dependencies": { "date-fns": "^3.6.0", diff --git a/packages/ra-core/src/form/Form.stories.tsx b/packages/ra-core/src/form/Form.stories.tsx index 5d869fa60a9..844c8b2e96a 100644 --- a/packages/ra-core/src/form/Form.stories.tsx +++ b/packages/ra-core/src/form/Form.stories.tsx @@ -26,7 +26,7 @@ import { required, ValidationError } from './validation'; import { mergeTranslations } from '../i18n'; import { I18nProvider, RaRecord } from '../types'; import { TestMemoryRouter, LinkBase as Link } from '../routing'; -import { CompatHashRouter } from '../routing/CompatHashRouter'; +import { HashRouter } from 'react-router-dom'; import { useNotificationContext } from '../notification'; export default { @@ -403,14 +403,16 @@ export const InNonDataRouter = ({ }: { i18nProvider?: I18nProvider; }) => ( - + Go to form} /> } /> - +
    ); const PostEditWithDelete = ({ diff --git a/packages/ra-core/src/routing/CompatHashRouter.spec.tsx b/packages/ra-core/src/routing/CompatHashRouter.spec.tsx deleted file mode 100644 index 0411582a3aa..00000000000 --- a/packages/ra-core/src/routing/CompatHashRouter.spec.tsx +++ /dev/null @@ -1,63 +0,0 @@ -import * as React from 'react'; -import { useEffect } from 'react'; -import expect from 'expect'; -import { fireEvent, render, screen } from '@testing-library/react'; -import { useLocation, useNavigate } from 'react-router'; - -import { CompatHashRouter } from './CompatHashRouter'; - -describe('CompatHashRouter', () => { - it('should render its children', async () => { - render( - - Hello - - ); - await screen.findByText('Hello'); - }); - - it('should display the error message when a child throws during render', async () => { - const consoleError = jest - .spyOn(console, 'error') - .mockImplementation(() => {}); - const Throw = () => { - throw new Error('boom'); - }; - render( - - - - ); - await screen.findByText('boom'); - await screen.findByText('Unexpected Application Error!'); - consoleError.mockRestore(); - }); - - it('should not remount its children on navigation', async () => { - let mountCount = 0; - const Child = () => { - const navigate = useNavigate(); - const location = useLocation(); - useEffect(() => { - mountCount += 1; - }, []); - return ( - - ); - }; - render( - - - - ); - const button = await screen.findByText('/'); - expect(mountCount).toBe(1); - fireEvent.click(button); - // Navigation happened... - await screen.findByText('/other'); - // ...but the children were not remounted. - expect(mountCount).toBe(1); - }); -}); diff --git a/packages/ra-core/src/routing/CompatHashRouter.tsx b/packages/ra-core/src/routing/CompatHashRouter.tsx deleted file mode 100644 index bd6ebafd0a5..00000000000 --- a/packages/ra-core/src/routing/CompatHashRouter.tsx +++ /dev/null @@ -1,256 +0,0 @@ -import * as React from 'react'; -import { useLayoutEffect, useRef, useState } from 'react'; -import { createPath, NavigationType, parsePath, Router } from 'react-router'; -import type { Location, Navigator, Path, To } from 'react-router'; -import type { RouterWrapperProps } from './RouterProvider'; - -type HashHistory = Navigator & { - readonly action: NavigationType; - readonly location: Location; - listen( - listener: (update: { - action: NavigationType; - location: Location; - }) => void - ): () => void; -}; - -const createKey = () => Math.random().toString(36).substring(2, 10); - -/** - * Minimal hash history reimplementation react-router v6, where - * `createHashRouter` is not exported from the `react-router` package (it - * lived only in `react-router-dom`), nor the low-level `createHashHistory`. - * Mirrors `createHashHistory` and builds upon the browser History API. - * It feeds into the low-level `` component which is available - * on every supported version. - */ -const createHashHistory = (): HashHistory => { - const globalHistory = window.history; - let action: NavigationType = NavigationType.Pop; - let index: number; - let location: Location; - const listeners = new Set< - (update: { action: NavigationType; location: Location }) => void - >(); - - const getIndexAndLocation = (): [number, Location] => { - const { - pathname = '/', - search = '', - hash = '', - } = parsePath(window.location.hash.substring(1)); - const historyState = globalHistory.state || {}; - return [ - historyState.idx, - { - pathname, - search, - hash, - state: historyState.usr ?? null, - key: historyState.key ?? 'default', - }, - ]; - }; - - [index, location] = getIndexAndLocation(); - if (index == null) { - index = 0; - globalHistory.replaceState({ ...globalHistory.state, idx: index }, ''); - } - - const getNextLocation = (to: To, state: any = null): Location => { - const parsed = typeof to === 'string' ? parsePath(to) : to; - return { - pathname: location.pathname, - search: '', - hash: '', - ...parsed, - state, - key: createKey(), - } as Location; - }; - - const getHistoryStateAndUrl = ( - nextLocation: Location, - nextIndex: number - ): [any, string] => [ - { usr: nextLocation.state, key: nextLocation.key, idx: nextIndex }, - '#' + createPath(nextLocation), - ]; - - const notify = () => { - listeners.forEach(listener => listener({ action, location })); - }; - - const handlePop = () => { - action = NavigationType.Pop; - const [nextIndex, nextLocation] = getIndexAndLocation(); - index = nextIndex ?? 0; - location = nextLocation; - notify(); - }; - - window.addEventListener('popstate', handlePop); - window.addEventListener('hashchange', () => { - const [, nextLocation] = getIndexAndLocation(); - // popstate already handles back/forward; only react to manual hash edits. - if (createPath(nextLocation) !== createPath(location)) { - handlePop(); - } - }); - - return { - get action() { - return action; - }, - get location() { - return location; - }, - createHref: (to: To) => - '#' + (typeof to === 'string' ? to : createPath(to)), - encodeLocation: (to: To): Path => { - const path = typeof to === 'string' ? parsePath(to) : to; - return { - pathname: path.pathname || '', - search: path.search || '', - hash: path.hash || '', - }; - }, - push: (to: To, state?: any) => { - action = NavigationType.Push; - location = getNextLocation(to, state); - index += 1; - const [historyState, url] = getHistoryStateAndUrl(location, index); - try { - globalHistory.pushState(historyState, '', url); - } catch { - // iOS limits history.pushState calls; fall back to a hard reload. - window.location.assign(url); - } - notify(); - }, - replace: (to: To, state?: any) => { - action = NavigationType.Replace; - location = getNextLocation(to, state); - const [historyState, url] = getHistoryStateAndUrl(location, index); - globalHistory.replaceState(historyState, '', url); - notify(); - }, - go: (delta: number) => globalHistory.go(delta), - listen: listener => { - listeners.add(listener); - return () => { - listeners.delete(listener); - }; - }, - }; -}; - -const errorPreStyle: React.CSSProperties = { - padding: '0.5rem', - backgroundColor: 'rgba(200, 200, 200, 0.5)', -}; - -interface RouterErrorBoundaryProps { - children: React.ReactNode; - location: Location; -} - -interface RouterErrorBoundaryState { - error: Error | null; - location: Location; -} - -/** - * Mirrors react-router's default error boundary, which data routers provide out of the box - * through `RouterProvider`. The low-level `` used by `CompatHashRouter` has no such - * boundary, so without this a render error thrown by a descendant would propagate uncaught - * instead of being displayed. The error is cleared on navigation (like react-router's data - * router), without remounting the children while no error is displayed. - */ -class RouterErrorBoundary extends React.Component< - RouterErrorBoundaryProps, - RouterErrorBoundaryState -> { - state: RouterErrorBoundaryState = { - error: null, - location: this.props.location, - }; - - static getDerivedStateFromError(error: Error) { - return { error }; - } - - static getDerivedStateFromProps( - props: RouterErrorBoundaryProps, - state: RouterErrorBoundaryState - ): Partial { - // Clear a displayed error once the user navigates away from it. - if (state.error != null && props.location !== state.location) { - return { error: null, location: props.location }; - } - return { location: props.location }; - } - - componentDidCatch(error: Error, info: React.ErrorInfo) { - console.error( - 'React Router caught the following error during render', - error, - info - ); - } - - render() { - const { error } = this.state; - if (error == null) { - return this.props.children; - } - const message = error instanceof Error ? error.message : String(error); - const stack = error instanceof Error ? error.stack : null; - return ( - <> -

    Unexpected Application Error!

    -

    {message}

    - {stack ?
    {stack}
    : null} - - ); - } -} - -/** - * Minimal non-data `` reimplementation for the react-router v6, where `HashRouter` - * is not exported from the `react-router` package (it lived only in `react-router-dom`). - * - * Used as the v6 fallback by the default `InternalRouter`, which prefers react-router's native - * `createHashRouter` when it exists (v7/v8). v6 users can always override this by wrapping - * the react element tree with `react-router-dom`'s `` component. - */ -export const CompatHashRouter = ({ - basename, - children, -}: RouterWrapperProps) => { - const historyRef = useRef(undefined); - if (historyRef.current == null) { - historyRef.current = createHashHistory(); - } - const history = historyRef.current!; - const [state, setState] = useState({ - action: history.action, - location: history.location, - }); - useLayoutEffect(() => history.listen(setState), [history]); - - return ( - - - {children} - - - ); -}; diff --git a/packages/ra-core/src/routing/CompatLink.tsx b/packages/ra-core/src/routing/CompatLink.tsx deleted file mode 100644 index f68cdfa83ea..00000000000 --- a/packages/ra-core/src/routing/CompatLink.tsx +++ /dev/null @@ -1,100 +0,0 @@ -import * as React from 'react'; -import { forwardRef } from 'react'; -import { - createPath, - useHref, - useLocation, - useNavigate, - useResolvedPath, -} from 'react-router'; -import type { To } from 'react-router'; -import type { RouterLinkProps } from './RouterProvider'; - -const isModifiedEvent = (event: React.MouseEvent) => - !!(event.metaKey || event.altKey || event.ctrlKey || event.shiftKey); - -const shouldProcessLinkClick = (event: React.MouseEvent, target?: string) => - event.button === 0 && // ignore everything but left clicks - (!target || target === '_self') && // let the browser handle "target=_blank" etc. - !isModifiedEvent(event); // ignore clicks with modifier keys - -type CompatLinkProps = RouterLinkProps & { - target?: string; - relative?: 'route' | 'path'; - reloadDocument?: boolean; - preventScrollReset?: boolean; - viewTransition?: boolean; - onClick?: React.MouseEventHandler; -}; - -/** - * Minimal `` reimplementation for react-router v6, where `Link` is not - * exported from the `react-router` package (it lived only in `react-router-dom`). - * Mirrors react-router's own `Link` using only primitives available on every - * supported version (`useHref`, `useResolvedPath`, `useNavigate`, `createPath`). - * - * Used as the v6 fallback by the default react-router adapter, which prefers - * react-router's native `Link` when it exists (v7/v8). v6 users can always override - * this with the `react-router-dom`'s `` component. - */ -export const CompatLink = forwardRef( - function CompatLink( - { - children, - onClick, - relative, - reloadDocument, - replace, - state, - target, - to, - preventScrollReset, - viewTransition, - ...rest - }, - ref - ) { - const href = useHref(to as To, { relative }); - const navigate = useNavigate(); - const location = useLocation(); - const path = useResolvedPath(to as To, { relative }); - - const handleClick = ( - event: React.MouseEvent - ) => { - onClick?.(event); - if ( - !event.defaultPrevented && - !reloadDocument && - shouldProcessLinkClick(event, target) - ) { - event.preventDefault(); - // If the URL hasn't changed, a regular will do a replace - // instead of a push, so we do the same here unless overridden. - const replaceProp = - replace !== undefined - ? replace - : createPath(location) === createPath(path); - navigate(to as To, { - replace: replaceProp, - state, - preventScrollReset, - relative, - viewTransition, - }); - } - }; - - return ( - - {children} - - ); - } -); diff --git a/packages/ra-core/src/routing/adapters/reactRouterProvider.tsx b/packages/ra-core/src/routing/adapters/reactRouterProvider.tsx index bdf4a80cdcc..740b4584cc5 100644 --- a/packages/ra-core/src/routing/adapters/reactRouterProvider.tsx +++ b/packages/ra-core/src/routing/adapters/reactRouterProvider.tsx @@ -1,6 +1,5 @@ import * as React from 'react'; -import { useContext, useEffect, useRef } from 'react'; -import * as ReactRouter from 'react-router'; +import { useContext, useEffect, useRef, ReactNode } from 'react'; import { useNavigate as useReactRouterNavigate, useLocation, @@ -18,26 +17,17 @@ import { UNSAFE_DataRouterStateContext, type FutureConfig, } from 'react-router'; -import { CompatLink } from '../CompatLink'; -import { CompatHashRouter } from '../CompatHashRouter'; +import { Link, createHashRouter } from 'react-router-dom'; import type { RouterProvider, RouterWrapperProps, RouterNavigateFunction, - RouterLinkProps, } from '../RouterProvider'; const routerProviderFuture: Partial< Pick > = { v7_startTransition: false, v7_relativeSplatPath: false }; -// Allow conditionally check whether `Link` and `createHashRouter` are exported during runtime. -// Fallback to `CompatLink` and `CompatHashRouter` if they are not available. -const reactRouter = ReactRouter as unknown as Record; - -const Link = (reactRouter.Link ?? - CompatLink) as React.ComponentType; - /** * Hook to check if navigation blocking is supported. * In react-router, blocking requires a data router. @@ -77,34 +67,26 @@ const useNavigate = (): RouterNavigateFunction => { * Internal router component that creates a HashRouter. * Only used when not already inside a router context. */ -const InternalRouter = ({ basename, children }: RouterWrapperProps) => { - const createHashRouter = reactRouter.createHashRouter as - | ((routes: any[], opts?: any) => any) - | undefined; - - if (createHashRouter) { - const router = createHashRouter( - [{ path: '*', element: <>{children} }], - { - basename, - future: { - v7_fetcherPersist: false, - v7_normalizeFormMethod: false, - v7_partialHydration: false, - v7_relativeSplatPath: false, - v7_skipActionErrorRevalidation: false, - }, - } - ); - return ( - - ); - } - - return {children}; +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 ( + + ); }; /** @@ -122,7 +104,7 @@ const RouterWrapper = ({ basename, children }: RouterWrapperProps) => { }; /** - * Default router provider using react-router. + * Default router provider using react-router-dom. * This provider is used by default when no custom routerProvider is provided to . */ export const reactRouterProvider: RouterProvider = { @@ -138,7 +120,7 @@ export const reactRouterProvider: RouterProvider = { // Components Link, Navigate, - Route: Route as RouterProvider['Route'], + Route, Routes, Outlet, diff --git a/packages/ra-ui-materialui/package.json b/packages/ra-ui-materialui/package.json index bb1f17d0537..51be0e3df4e 100644 --- a/packages/ra-ui-materialui/package.json +++ b/packages/ra-ui-materialui/package.json @@ -65,7 +65,8 @@ "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 || ^8.0.0" + "react-router": "^6.28.1 || ^7.1.1", + "react-router-dom": "^6.28.1 || ^7.1.1" }, "dependencies": { "autosuggest-highlight": "^3.1.1", diff --git a/packages/react-admin/package.json b/packages/react-admin/package.json index ff551828891..4c0fcf799a0 100644 --- a/packages/react-admin/package.json +++ b/packages/react-admin/package.json @@ -51,7 +51,8 @@ "ra-language-english": "^5.14.7", "ra-ui-materialui": "^5.14.7", "react-hook-form": "^7.72.0", - "react-router": "^6.28.1 || ^7.1.1 || ^8.0.0" + "react-router": "^6.28.1 || ^7.1.1", + "react-router-dom": "^6.28.1 || ^7.1.1" }, "gitHead": "19dcb264898c8e01c408eb66ce02c50b67c851ab", "exports": { diff --git a/yarn.lock b/yarn.lock index da9946c23d3..03ee233dcb0 100644 --- a/yarn.lock +++ b/yarn.lock @@ -20411,7 +20411,8 @@ __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 || ^8.0.0 + react-router: ^6.28.1 || ^7.1.1 + react-router-dom: ^6.28.1 || ^7.1.1 languageName: unknown linkType: soft @@ -20757,7 +20758,8 @@ __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 || ^8.0.0 + react-router: ^6.28.1 || ^7.1.1 + react-router-dom: ^6.28.1 || ^7.1.1 languageName: unknown linkType: soft From e85b130898b89f94f16c191975ee9ca19477b3c7 Mon Sep 17 00:00:00 2001 From: Shaoyu Meng Date: Tue, 23 Jun 2026 12:48:15 -0700 Subject: [PATCH 08/56] build: Keep react-router ^8.0.0 in the peer/dependency range Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/ra-core/package.json | 2 +- packages/ra-ui-materialui/package.json | 2 +- packages/react-admin/package.json | 2 +- yarn.lock | 4 ++-- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/ra-core/package.json b/packages/ra-core/package.json index 3e495970212..4bf267d66a3 100644 --- a/packages/ra-core/package.json +++ b/packages/ra-core/package.json @@ -61,7 +61,7 @@ "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": "^6.28.1 || ^7.1.1 || ^8.0.0", "react-router-dom": "^6.28.1 || ^7.1.1" }, "dependencies": { diff --git a/packages/ra-ui-materialui/package.json b/packages/ra-ui-materialui/package.json index 51be0e3df4e..3f481073dbc 100644 --- a/packages/ra-ui-materialui/package.json +++ b/packages/ra-ui-materialui/package.json @@ -65,7 +65,7 @@ "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": "^6.28.1 || ^7.1.1 || ^8.0.0", "react-router-dom": "^6.28.1 || ^7.1.1" }, "dependencies": { diff --git a/packages/react-admin/package.json b/packages/react-admin/package.json index 4c0fcf799a0..ff71465d403 100644 --- a/packages/react-admin/package.json +++ b/packages/react-admin/package.json @@ -51,7 +51,7 @@ "ra-language-english": "^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": "^6.28.1 || ^7.1.1 || ^8.0.0", "react-router-dom": "^6.28.1 || ^7.1.1" }, "gitHead": "19dcb264898c8e01c408eb66ce02c50b67c851ab", diff --git a/yarn.lock b/yarn.lock index 03ee233dcb0..94fdd2ec530 100644 --- a/yarn.lock +++ b/yarn.lock @@ -20411,7 +20411,7 @@ __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: ^6.28.1 || ^7.1.1 || ^8.0.0 react-router-dom: ^6.28.1 || ^7.1.1 languageName: unknown linkType: soft @@ -20758,7 +20758,7 @@ __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: ^6.28.1 || ^7.1.1 || ^8.0.0 react-router-dom: ^6.28.1 || ^7.1.1 languageName: unknown linkType: soft From 69955ef9694399d3d40496c10ec031439a901a70 Mon Sep 17 00:00:00 2001 From: Shaoyu Meng Date: Tue, 23 Jun 2026 13:09:34 -0700 Subject: [PATCH 09/56] build: Remove unused react-router-dom from ra-ui-materialui No code in ra-ui-materialui imports react-router-dom; all routing imports come from react-router. ra-core declares its own react-router-dom peer for its router adapter. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/ra-ui-materialui/package.json | 15 ++++----------- yarn.lock | 2 -- 2 files changed, 4 insertions(+), 13 deletions(-) diff --git a/packages/ra-ui-materialui/package.json b/packages/ra-ui-materialui/package.json index 3f481073dbc..b7ea52d09ec 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" }, @@ -65,8 +64,7 @@ "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 || ^8.0.0", - "react-router-dom": "^6.28.1 || ^7.1.1" + "react-router": "^6.28.1 || ^7.1.1 || ^8.0.0" }, "dependencies": { "autosuggest-highlight": "^3.1.1", @@ -86,14 +84,9 @@ "gitHead": "19dcb264898c8e01c408eb66ce02c50b67c851ab", "exports": { ".": { - "import": { - "types": "./dist/index.d.ts", - "default": "./dist/index.js" - }, - "require": { - "types": "./dist/index.d.cts", - "default": "./dist/index.cjs" - } + "types": "./dist/index.d.cts", + "import": "./dist/index.js", + "require": "./dist/index.cjs" } } } diff --git a/yarn.lock b/yarn.lock index 94fdd2ec530..760942f241b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -20742,7 +20742,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" @@ -20759,7 +20758,6 @@ __metadata: react-hook-form: "*" react-is: ^18.0.0 || ^19.0.0 react-router: ^6.28.1 || ^7.1.1 || ^8.0.0 - react-router-dom: ^6.28.1 || ^7.1.1 languageName: unknown linkType: soft From 802469ed1505dcf0ed21496fe281361770036326 Mon Sep 17 00:00:00 2001 From: Shaoyu Meng Date: Tue, 23 Jun 2026 13:19:33 -0700 Subject: [PATCH 10/56] fix: Restore canonical nested exports for ra-core and ra-ui-materialui Individual zshy builds had rewritten the exports field to the flat pre-normalization form. Restore the nested import/require form that update-package-exports produces and that matches upstream. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/ra-core/package.json | 11 ++++++++--- packages/ra-ui-materialui/package.json | 11 ++++++++--- 2 files changed, 16 insertions(+), 6 deletions(-) diff --git a/packages/ra-core/package.json b/packages/ra-core/package.json index 4bf267d66a3..6a15a7692ac 100644 --- a/packages/ra-core/package.json +++ b/packages/ra-core/package.json @@ -15,9 +15,14 @@ "type": "module", "exports": { ".": { - "types": "./dist/index.d.cts", - "import": "./dist/index.js", - "require": "./dist/index.cjs" + "import": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + }, + "require": { + "types": "./dist/index.d.cts", + "default": "./dist/index.cjs" + } } }, "sideEffects": false, diff --git a/packages/ra-ui-materialui/package.json b/packages/ra-ui-materialui/package.json index b7ea52d09ec..56c40b7136a 100644 --- a/packages/ra-ui-materialui/package.json +++ b/packages/ra-ui-materialui/package.json @@ -84,9 +84,14 @@ "gitHead": "19dcb264898c8e01c408eb66ce02c50b67c851ab", "exports": { ".": { - "types": "./dist/index.d.cts", - "import": "./dist/index.js", - "require": "./dist/index.cjs" + "import": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + }, + "require": { + "types": "./dist/index.d.cts", + "default": "./dist/index.cjs" + } } } } From cb85c7dfd98d458a0487d0665991ebe1c13ca681 Mon Sep 17 00:00:00 2001 From: Shaoyu Meng Date: Tue, 23 Jun 2026 13:56:04 -0700 Subject: [PATCH 11/56] build: Exclude ESM-only ra-router-react-router-v8 from update-package-exports update-package-exports forces dual import/require exports on every package; the ESM-only v8 adapter must keep its ESM-only exports (it has no .cjs build). Also keep minor README/story/doc wording. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/ReactRouterV8.md | 4 ++-- packages/ra-core/src/form/Form.stories.tsx | 2 +- packages/ra-router-react-router-v8/README.md | 12 ++---------- scripts/update-package-exports.ts | 7 ++++++- 4 files changed, 11 insertions(+), 14 deletions(-) diff --git a/docs/ReactRouterV8.md b/docs/ReactRouterV8.md index 5a496334110..0523b555819 100644 --- a/docs/ReactRouterV8.md +++ b/docs/ReactRouterV8.md @@ -45,11 +45,11 @@ That's it! React-admin will now use React Router v8 for all routing operations. ## Standalone Mode -When using `reactRouterV8Provider` without an existing React Router, react-admin creates its own hash router automatically (URLs like `/#/posts`). This is the simplest setup and requires no additional configuration. +When using `reactRouterV8Provider` 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 -When react-admin is rendered inside an existing React Router context, the provider detects it and uses that router instead of creating a new one, so react-admin integrates into a larger React Router v8 application. +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 diff --git a/packages/ra-core/src/form/Form.stories.tsx b/packages/ra-core/src/form/Form.stories.tsx index 844c8b2e96a..b1312be19aa 100644 --- a/packages/ra-core/src/form/Form.stories.tsx +++ b/packages/ra-core/src/form/Form.stories.tsx @@ -12,6 +12,7 @@ import polyglotI18nProvider from 'ra-i18n-polyglot'; import englishMessages from 'ra-language-english'; import fakeRestDataProvider from 'ra-data-fakerest'; import { Route, Routes, useNavigate, useLocation } from 'react-router'; +import { HashRouter } from 'react-router-dom'; import { CoreAdminContext } from '../core'; import { @@ -26,7 +27,6 @@ import { required, ValidationError } from './validation'; import { mergeTranslations } from '../i18n'; import { I18nProvider, RaRecord } from '../types'; import { TestMemoryRouter, LinkBase as Link } from '../routing'; -import { HashRouter } from 'react-router-dom'; import { useNotificationContext } from '../notification'; export default { diff --git a/packages/ra-router-react-router-v8/README.md b/packages/ra-router-react-router-v8/README.md index 0fed908a867..28692f7d1bf 100644 --- a/packages/ra-router-react-router-v8/README.md +++ b/packages/ra-router-react-router-v8/README.md @@ -18,7 +18,7 @@ yarn add ra-router-react-router-v8 react-router@^8 ## Usage -Pass the `reactRouterV8Provider` to the `routerProvider` prop of the `` component: +Use the `reactRouterV8Provider` as the `routerProvider` prop on ``: ```tsx import { Admin, Resource } from 'react-admin'; @@ -41,14 +41,6 @@ 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. -## Development - -This package ships with Storybook stories (`*.stories.tsx`) covering the adapter's -behavior. Unit tests are not included yet: React Router v8 requires React 19, while -react-admin's test suite currently runs on React 18, so the stories cannot be -exercised by the shared Jest run. Adding a React 19 test lane for this package is a -follow-up once the project's test environment supports React 19. - ## License -This package is licensed under the MIT License. +MIT diff --git a/scripts/update-package-exports.ts b/scripts/update-package-exports.ts index 99a7b903fbd..39794d7cd6c 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-v8 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-v8', +]); const updatePackages = async () => { const packageNames = (await fs.promises.readdir(packagesDir)) From 61e650dcf355d217bb834927282cd0dd0872fe52 Mon Sep 17 00:00:00 2001 From: Shaoyu Meng Date: Tue, 23 Jun 2026 14:07:40 -0700 Subject: [PATCH 12/56] build: Drop redundant module/moduleResolution from v8 adapter tsconfig zshy's ESM build pass already uses Bundler resolution and cjs is disabled, so these compilerOptions are not needed for the build. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/ra-router-react-router-v8/tsconfig.json | 2 -- 1 file changed, 2 deletions(-) diff --git a/packages/ra-router-react-router-v8/tsconfig.json b/packages/ra-router-react-router-v8/tsconfig.json index af844224073..15b7d56fb2d 100644 --- a/packages/ra-router-react-router-v8/tsconfig.json +++ b/packages/ra-router-react-router-v8/tsconfig.json @@ -5,8 +5,6 @@ "rootDir": "src", "allowJs": false, "strictNullChecks": true, - "module": "esnext", - "moduleResolution": "bundler" }, "exclude": ["**/*.spec.ts", "**/*.spec.tsx"], "include": ["src"] From eb8cabf2888d9abd628941dc02925108906d738f Mon Sep 17 00:00:00 2001 From: Shaoyu Meng Date: Wed, 24 Jun 2026 15:26:02 -0700 Subject: [PATCH 13/56] feat: Extract react-router adapters into standalone packages Move the default react-router v6 provider out of ra-core into a new ra-router-react-router package, and rename the React Router v8 adapter from ra-router-react-router-v8 to ra-router-react-router-next. Why: - Decouples react-router from ra-core/ra-ui-materialui/react-admin: they now keep react-router only as a devDependency (for tests), while the adapter package carries the runtime dependency. This lets the router library evolve independently of the framework packages. - Sets up the v6 migration path: ra-router-react-router-next (v8) is the recommended choice for new projects and will be republished as ra-router-react-router in react-admin v6, while the v6 default is kept for backward compatibility. The new package is a dependency-free leaf (it mirrors ra-core's RouterProvider contract with local types instead of importing it) so it can build before ra-core, avoiding a circular build dependency; ra-core enforces interface conformance at the consumption site. ra-core and react-admin now depend on ra-router-react-router; ra-core re-exports reactRouterProvider for backward compatibility. Co-Authored-By: Claude Opus 4.8 (1M context) --- Makefile | 14 +- docs/ReactRouterV8.md | 20 +- docs/Routing.md | 2 +- packages/ra-core/package.json | 5 +- packages/ra-core/src/routing/README.md | 9 +- .../src/routing/RouterProviderContext.tsx | 2 +- packages/ra-core/src/routing/index.ts | 2 +- .../README.md | 21 +- .../package.json | 4 +- .../ra-router-react-router-next/src/index.ts | 1 + .../src/reactRouterNextProvider.stories.tsx} | 26 +- .../src/reactRouterNextProvider.tsx} | 9 +- .../tsconfig.json | 0 .../ra-router-react-router-v8/src/index.ts | 1 - packages/ra-router-react-router/README.md | 49 +++ packages/ra-router-react-router/package.json | 48 +++ packages/ra-router-react-router/src/index.ts | 1 + .../src/reactRouterProvider.stories.tsx | 341 ++++++++++++++++++ .../src}/reactRouterProvider.tsx | 35 +- packages/ra-router-react-router/tsconfig.json | 11 + packages/ra-ui-materialui/package.json | 3 +- packages/react-admin/package.json | 5 +- scripts/update-package-exports.ts | 4 +- yarn.lock | 23 +- 24 files changed, 567 insertions(+), 69 deletions(-) rename packages/{ra-router-react-router-v8 => ra-router-react-router-next}/README.md (51%) rename packages/{ra-router-react-router-v8 => ra-router-react-router-next}/package.json (85%) create mode 100644 packages/ra-router-react-router-next/src/index.ts rename packages/{ra-router-react-router-v8/src/reactRouterV8Provider.stories.tsx => ra-router-react-router-next/src/reactRouterNextProvider.stories.tsx} (92%) rename packages/{ra-router-react-router-v8/src/reactRouterV8Provider.tsx => ra-router-react-router-next/src/reactRouterNextProvider.tsx} (88%) rename packages/{ra-router-react-router-v8 => ra-router-react-router-next}/tsconfig.json (100%) delete mode 100644 packages/ra-router-react-router-v8/src/index.ts create mode 100644 packages/ra-router-react-router/README.md create mode 100644 packages/ra-router-react-router/package.json create mode 100644 packages/ra-router-react-router/src/index.ts create mode 100644 packages/ra-router-react-router/src/reactRouterProvider.stories.tsx rename packages/{ra-core/src/routing/adapters => ra-router-react-router/src}/reactRouterProvider.tsx (76%) create mode 100644 packages/ra-router-react-router/tsconfig.json diff --git a/Makefile b/Makefile index 236ac24cc6e..c02302abca6 100644 --- a/Makefile +++ b/Makefile @@ -48,6 +48,12 @@ build-offline: ## build the offline example preview-offline: ## preview the offline example @yarn preview-offline +# ra-router-react-router exposes the default react-router v6/v7 provider and is a +# dependency of ra-core, so it must be built before ra-core. +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,9 +62,9 @@ build-ra-router-tanstack: @echo "Transpiling ra-router-tanstack files..."; @cd ./packages/ra-router-tanstack && yarn build -build-ra-router-react-router-v8: - @echo "Transpiling ra-router-react-router-v8 files..."; - @cd ./packages/ra-router-react-router-v8 && 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..."; @@ -133,7 +139,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-router-react-router-v8 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/ReactRouterV8.md b/docs/ReactRouterV8.md index 0523b555819..2da971002a5 100644 --- a/docs/ReactRouterV8.md +++ b/docs/ReactRouterV8.md @@ -5,33 +5,35 @@ title: "React Router v8 Integration" # React Router v8 Integration -By default, react-admin is powered by [React Router](https://reactrouter.com/) v6/v7 through its built-in router adapter. 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-v8`. +By default, react-admin is powered by [React Router](https://reactrouter.com/) v6 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 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-v8 react-router@^8 +npm install ra-router-react-router-next react-router@^8 # or -yarn add ra-router-react-router-v8 react-router@^8 +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 `reactRouterV8Provider`: +Set the `` to `reactRouterNextProvider`: ```tsx import { Admin, Resource, ListGuesser } from 'react-admin'; -import { reactRouterV8Provider } from 'ra-router-react-router-v8'; +import { reactRouterNextProvider } from 'ra-router-react-router-next'; import { dataProvider } from './dataProvider'; const App = () => ( @@ -45,7 +47,7 @@ That's it! React-admin will now use React Router v8 for all routing operations. ## Standalone Mode -When using `reactRouterV8Provider` 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. +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 @@ -53,5 +55,5 @@ If your application already uses React Router, you can embed react-admin inside ## When To Use This Package -- Use the **built-in** react-router adapter (no extra package) if your app runs on React Router v6 or v7. -- Use **`ra-router-react-router-v8`** if your app runs on React Router v8 (and therefore React 19). +- Use the **default** `ra-router-react-router` adapter (installed automatically, no extra setup) if your app runs on React Router v6. +- 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/Routing.md b/docs/Routing.md index d8683407488..f0051edd1b2 100644 --- a/docs/Routing.md +++ b/docs/Routing.md @@ -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 (v6/v7) 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. +React-admin supports multiple routing libraries through its router abstraction layer. By default, it uses react-router (v6) 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: diff --git a/packages/ra-core/package.json b/packages/ra-core/package.json index 6a15a7692ac..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 || ^8.0.0", - "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/routing/README.md b/packages/ra-core/src/routing/README.md index e653ef543aa..0a9e48c7d9a 100644 --- a/packages/ra-core/src/routing/README.md +++ b/packages/ra-core/src/routing/README.md @@ -32,9 +32,9 @@ 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) + └── tanStackRouterProvider (alternative implementation, in the ra-router-tanstack package) ``` ### Context Flow @@ -147,6 +147,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 (`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/index.ts b/packages/ra-core/src/routing/index.ts index 12e527ee7d4..a29c6fde738 100644 --- a/packages/ra-core/src/routing/index.ts +++ b/packages/ra-core/src/routing/index.ts @@ -14,7 +14,7 @@ export * from './TestMemoryRouter'; export * from './useSplatPathBase'; export * from './RouterProvider'; export * from './RouterProviderContext'; -export * from './adapters/reactRouterProvider'; +export { reactRouterProvider } from 'ra-router-react-router'; export * from './useLocation'; export * from './useNavigate'; export * from './useParams'; diff --git a/packages/ra-router-react-router-v8/README.md b/packages/ra-router-react-router-next/README.md similarity index 51% rename from packages/ra-router-react-router-v8/README.md rename to packages/ra-router-react-router-next/README.md index 28692f7d1bf..0403f03a990 100644 --- a/packages/ra-router-react-router-v8/README.md +++ b/packages/ra-router-react-router-next/README.md @@ -1,9 +1,14 @@ -# ra-router-react-router-v8 +# 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. Use this package to -run react-admin on React Router v8. +react-admin ships with a React Router v6 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 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`. @@ -11,23 +16,23 @@ run react-admin on React Router v8. ## Installation ```sh -npm install ra-router-react-router-v8 react-router@^8 +npm install ra-router-react-router-next react-router@^8 # or -yarn add ra-router-react-router-v8 react-router@^8 +yarn add ra-router-react-router-next react-router@^8 ``` ## Usage -Use the `reactRouterV8Provider` as the `routerProvider` prop on ``: +Use the `reactRouterNextProvider` as the `routerProvider` prop on ``: ```tsx import { Admin, Resource } from 'react-admin'; -import { reactRouterV8Provider } from 'ra-router-react-router-v8'; +import { reactRouterNextProvider } from 'ra-router-react-router-next'; import { dataProvider } from './dataProvider'; import { PostList, PostEdit, PostCreate } from './posts'; export const App = () => ( - + ( */ export const BasicStandalone = () => ( @@ -160,7 +160,7 @@ const CommentList = () => ( export const MultipleResources = () => ( @@ -174,7 +174,7 @@ export const MultipleResources = () => ( */ export const LinkComponent = () => ( @@ -189,7 +189,7 @@ const RedirectToPosts = () => ; export const NavigateComponent = () => ( @@ -212,7 +212,7 @@ const CustomPage = () => { export const CustomRoutesSupport = () => ( @@ -233,7 +233,7 @@ const ParamsReader = () => { export const UseParamsTest = () => ( @@ -254,7 +254,7 @@ const MatchReader = () => { export const UseMatchTest = () => ( @@ -270,7 +270,7 @@ export const UseMatchTest = () => ( */ export const UseLocationTest = () => ( @@ -288,7 +288,7 @@ const InRouterContextReader = () => { export const RouterContextTest = () => ( @@ -329,7 +329,7 @@ const BlockerForm = () => { export const UseBlockerTest = () => ( diff --git a/packages/ra-router-react-router-v8/src/reactRouterV8Provider.tsx b/packages/ra-router-react-router-next/src/reactRouterNextProvider.tsx similarity index 88% rename from packages/ra-router-react-router-v8/src/reactRouterV8Provider.tsx rename to packages/ra-router-react-router-next/src/reactRouterNextProvider.tsx index 880bfbeb63e..d6b8c82b365 100644 --- a/packages/ra-router-react-router-v8/src/reactRouterV8Provider.tsx +++ b/packages/ra-router-react-router-next/src/reactRouterNextProvider.tsx @@ -97,10 +97,13 @@ const RouterWrapper = ({ basename, children }: RouterWrapperProps) => { * 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. + * react-admin uses its built-in react-router v6 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. */ -export const reactRouterV8Provider: RouterProvider = { +// FIXME kept for BC: republish as ra-router-react-router for react-admin v6 +export const reactRouterNextProvider: RouterProvider = { // Hooks useNavigate, useLocation, diff --git a/packages/ra-router-react-router-v8/tsconfig.json b/packages/ra-router-react-router-next/tsconfig.json similarity index 100% rename from packages/ra-router-react-router-v8/tsconfig.json rename to packages/ra-router-react-router-next/tsconfig.json diff --git a/packages/ra-router-react-router-v8/src/index.ts b/packages/ra-router-react-router-v8/src/index.ts deleted file mode 100644 index 0e71ff07fdc..00000000000 --- a/packages/ra-router-react-router-v8/src/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './reactRouterV8Provider'; diff --git a/packages/ra-router-react-router/README.md b/packages/ra-router-react-router/README.md new file mode 100644 index 00000000000..94ef5a67e52 --- /dev/null +++ b/packages/ra-router-react-router/README.md @@ -0,0 +1,49 @@ +# ra-router-react-router + +[React Router v6](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 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 +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..dddca32ad01 --- /dev/null +++ b/packages/ra-router-react-router/package.json @@ -0,0 +1,48 @@ +{ + "name": "ra-router-react-router", + "version": "5.14.7", + "description": "React Router v6 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": { + "typescript": "^5.1.3", + "zshy": "^0.5.0" + }, + "peerDependencies": { + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "dependencies": { + "react-router": "^6.28.1", + "react-router-dom": "^6.28.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.stories.tsx b/packages/ra-router-react-router/src/reactRouterProvider.stories.tsx new file mode 100644 index 00000000000..d9522ba5c7c --- /dev/null +++ b/packages/ra-router-react-router/src/reactRouterProvider.stories.tsx @@ -0,0 +1,341 @@ +import * as React from 'react'; +import fakeDataProvider from 'ra-data-fakerest'; +import { + CoreAdmin, + Resource, + CustomRoutes, + ListBase, + ShowBase, + EditBase, + CreateBase, + useRecordContext, + useNavigate, + useLocation, + LinkBase, + useBlocker, + Form, + testUI, +} from 'ra-core'; +import { reactRouterProvider } from './reactRouterProvider'; + +const { useParams, useMatch, useInRouterContext, Route, Navigate } = + reactRouterProvider; +const { TextInput } = 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 LocationDisplay = () => { + const location = useLocation(); + return
    {location.pathname}
    ; +}; + +const Layout = ({ children }: { children?: React.ReactNode }) => ( +
    + + {children} +
    +); + +const PostList = () => { + const navigate = useNavigate(); + return ( + ( +
    +

    Posts

    +
      + {data?.map(record => ( +
    • + + {record.title} + +
    • + ))} +
    + +
    + )} + /> + ); +}; + +const PostShow = () => { + const navigate = useNavigate(); + const { id } = useParams<{ id: string }>(); + return ( + + navigate(`/posts/${id}`)} /> + + ); +}; + +const PostShowView = ({ onEdit }: { onEdit: () => void }) => { + const record = useRecordContext(); + if (!record) return null; + return ( +
    +

    {record.title}

    +

    {record.body}

    + + Back to list +
    + ); +}; + +const PostEdit = () => { + const { id } = useParams<{ id: string }>(); + return ( + +
    + + + +
    + ); +}; + +const PostCreate = () => ( + +
    + + + +
    +); + +/** + * BasicStandalone: react-admin runs on its own hash router created by the + * react-router provider (no surrounding router). + */ +export const BasicStandalone = () => ( + + + +); + +/** + * MultipleResources: several resources sharing the provider. + */ +const CommentList = () => ( + ( +
      + {data?.map(record =>
    • {record.body}
    • )} +
    + )} + /> +); + +export const MultipleResources = () => ( + + + + +); + +/** + * LinkComponent: navigation through LinkBase (which renders the provider Link). + */ +export const LinkComponent = () => ( + + + +); + +/** + * NavigateComponent: declarative redirect through the provider Navigate. + */ +const RedirectToPosts = () => ; + +export const NavigateComponent = () => ( + + + } /> + + + +); + +/** + * CustomRoutesSupport: a custom page rendered through CustomRoutes. + */ +const CustomPage = () => { + const location = useLocation(); + return ( +
    Custom page at {location.pathname}
    + ); +}; + +export const CustomRoutesSupport = () => ( + + + } /> + + + +); + +/** + * UseParamsTest: reads the record id from the URL params. + */ +const ParamsReader = () => { + const params = useParams(); + return
    {JSON.stringify(params)}
    ; +}; + +export const UseParamsTest = () => ( + + + } /> + + + +); + +/** + * UseMatchTest: matches the current location against a pattern. + */ +const MatchReader = () => { + const match = useMatch({ path: '/posts/:id/show' }); + return
    {JSON.stringify(match)}
    ; +}; + +export const UseMatchTest = () => ( + + + } /> + + + +); + +/** + * UseLocationTest: surfaces the current location. + */ +export const UseLocationTest = () => ( + + + +); + +/** + * RouterContextTest: confirms react-admin detects it is inside a router. + */ +const InRouterContextReader = () => { + const inContext = useInRouterContext(); + return
    {String(inContext)}
    ; +}; + +export const RouterContextTest = () => ( + + + } /> + + + +); + +/** + * UseBlockerTest: blocks navigation while a form is dirty. + */ +const BlockerForm = () => { + const [dirty, setDirty] = React.useState(false); + const blocker = useBlocker(dirty); + const navigate = useNavigate(); + return ( +
    + + + {blocker.state === 'blocked' ? ( +
    + + +
    + ) : null} +
    + ); +}; + +export const UseBlockerTest = () => ( + + + } /> + + + +); diff --git a/packages/ra-core/src/routing/adapters/reactRouterProvider.tsx b/packages/ra-router-react-router/src/reactRouterProvider.tsx similarity index 76% rename from packages/ra-core/src/routing/adapters/reactRouterProvider.tsx rename to packages/ra-router-react-router/src/reactRouterProvider.tsx index 740b4584cc5..1ad859e4205 100644 --- a/packages/ra-core/src/routing/adapters/reactRouterProvider.tsx +++ b/packages/ra-router-react-router/src/reactRouterProvider.tsx @@ -18,11 +18,29 @@ import { type FutureConfig, } from 'react-router'; import { Link, createHashRouter } from 'react-router-dom'; -import type { - RouterProvider, - RouterWrapperProps, - RouterNavigateFunction, -} from '../RouterProvider'; + +// This adapter is the default RouterProvider implementation, and ra-core depends +// on it to expose `reactRouterProvider`. To avoid a circular build dependency +// (ra-core -> ra-router-react-router -> ra-core), this package deliberately does +// NOT import ra-core's `RouterProvider` types. Instead it relies on local aliases +// that mirror ra-core's contract; conformance to the `RouterProvider` interface is +// enforced where ra-core consumes `reactRouterProvider`. +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; const routerProviderFuture: Partial< Pick @@ -106,12 +124,15 @@ const RouterWrapper = ({ basename, children }: RouterWrapperProps) => { /** * Default router provider using react-router-dom. * This provider is used by default when no custom routerProvider is provided to . + * + * It implements ra-core's `RouterProvider` interface (the conformance check happens + * in ra-core, which consumes this object as its default provider). */ -export const reactRouterProvider: RouterProvider = { +export const reactRouterProvider = { // Hooks useNavigate, useLocation, - useParams: useParams as RouterProvider['useParams'], + useParams: useParams as UseParams, useBlocker, useMatch, useInRouterContext, 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 56c40b7136a..7bba31365b2 100644 --- a/packages/ra-ui-materialui/package.json +++ b/packages/ra-ui-materialui/package.json @@ -63,8 +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 || ^8.0.0" + "react-is": "^18.0.0 || ^19.0.0" }, "dependencies": { "autosuggest-highlight": "^3.1.1", diff --git a/packages/react-admin/package.json b/packages/react-admin/package.json index ff71465d403..695036560fa 100644 --- a/packages/react-admin/package.json +++ b/packages/react-admin/package.json @@ -49,10 +49,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 || ^8.0.0", - "react-router-dom": "^6.28.1 || ^7.1.1" + "react-hook-form": "^7.72.0" }, "gitHead": "19dcb264898c8e01c408eb66ce02c50b67c851ab", "exports": { diff --git a/scripts/update-package-exports.ts b/scripts/update-package-exports.ts index 39794d7cd6c..7db27ef95a2 100644 --- a/scripts/update-package-exports.ts +++ b/scripts/update-package-exports.ts @@ -3,11 +3,11 @@ import fs from 'node:fs'; const packagesDir = path.join(__dirname, '..', 'packages'); const examplesDir = path.join(__dirname, '..', 'examples'); -// ra-router-react-router-v8 is ESM-only (react-router v8 is ESM-only), so it +// 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-v8', + 'ra-router-react-router-next', ]); const updatePackages = async () => { diff --git a/yarn.lock b/yarn.lock index 760942f241b..13e53b811e5 100644 --- a/yarn.lock +++ b/yarn.lock @@ -20395,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" @@ -20411,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 || ^8.0.0 - react-router-dom: ^6.28.1 || ^7.1.1 languageName: unknown linkType: soft @@ -20668,9 +20667,9 @@ __metadata: languageName: unknown linkType: soft -"ra-router-react-router-v8@workspace:packages/ra-router-react-router-v8": +"ra-router-react-router-next@workspace:packages/ra-router-react-router-next": version: 0.0.0-use.local - resolution: "ra-router-react-router-v8@workspace:packages/ra-router-react-router-v8" + resolution: "ra-router-react-router-next@workspace:packages/ra-router-react-router-next" dependencies: ra-core: "npm:^5.14.7" react-router: "npm:^8.0.0" @@ -20684,6 +20683,20 @@ __metadata: 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 + 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" @@ -20757,7 +20770,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 || ^8.0.0 languageName: unknown linkType: soft @@ -20939,6 +20951,7 @@ __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" From 0868f544d5d1dcdd2878f2494d1551aa3d070564 Mon Sep 17 00:00:00 2001 From: Shaoyu Meng Date: Wed, 24 Jun 2026 15:44:14 -0700 Subject: [PATCH 14/56] refactor: Export reactRouterProvider from ra-core root only Drop the reactRouterProvider re-export from the routing barrel and expose it once via `export * from 'ra-router-react-router'` at the end of ra-core's index. Internal consumers (CoreAdminContext) now import it directly from the package. Why: keeps a single, explicit source for the re-exported adapter instead of duplicating the re-export inside the routing module. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/ra-core/src/core/CoreAdminContext.tsx | 8 ++------ packages/ra-core/src/index.ts | 2 ++ packages/ra-core/src/routing/index.ts | 1 - 3 files changed, 4 insertions(+), 7 deletions(-) diff --git a/packages/ra-core/src/core/CoreAdminContext.tsx b/packages/ra-core/src/core/CoreAdminContext.tsx index 4f7e611e720..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, 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/index.ts b/packages/ra-core/src/routing/index.ts index a29c6fde738..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 { reactRouterProvider } from 'ra-router-react-router'; export * from './useLocation'; export * from './useNavigate'; export * from './useParams'; From 060de5e37aa28e76808787ea202d6ba31c9a2733 Mon Sep 17 00:00:00 2001 From: Shaoyu Meng Date: Wed, 24 Jun 2026 15:46:36 -0700 Subject: [PATCH 15/56] docs: List reactRouterNextProvider in routing README components Add the react-router v8 opt-in provider (ra-router-react-router-next) to the Key Components tree so it matches the Key Files Reference table. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/ra-core/src/routing/README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/ra-core/src/routing/README.md b/packages/ra-core/src/routing/README.md index 0a9e48c7d9a..8c2e0f8bb60 100644 --- a/packages/ra-core/src/routing/README.md +++ b/packages/ra-core/src/routing/README.md @@ -34,6 +34,8 @@ RouterProviderContext │ ├── reactRouterProvider (default implementation, in the ra-router-react-router package) │ + ├── 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) ``` From 0cbc7cabe2d83517128cc15e8bca744ade498c60 Mon Sep 17 00:00:00 2001 From: Shaoyu Meng Date: Wed, 24 Jun 2026 15:59:40 -0700 Subject: [PATCH 16/56] docs: Reference react-router instead of react-router-dom in routing README Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/ra-core/src/routing/README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/ra-core/src/routing/README.md b/packages/ra-core/src/routing/README.md index 8c2e0f8bb60..d160df01aa7 100644 --- a/packages/ra-core/src/routing/README.md +++ b/packages/ra-core/src/routing/README.md @@ -121,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 @@ -141,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 From e1a62e4b7c5536afa997e76d78037b6576d97f57 Mon Sep 17 00:00:00 2001 From: Shaoyu Meng Date: Wed, 24 Jun 2026 16:49:58 -0700 Subject: [PATCH 17/56] feat: Support react-router v6 and v7 in the default adapter Declare react-router/react-router-dom as `^6.28.1 || ^7.1.1` in both the dependencies and peerDependencies of ra-router-react-router, while pinning the build to v6 via devDependencies. This restores the v6/v7 support range for consumers while keeping the monorepo on a single hoisted v6 instance (so the adapter builds against v6 and shares react-router with ra-core, avoiding the duplicate-instance breakage a bare `^6 || ^7` range caused). Also restore the "v6/v7" wording across the README and docs to match the declared range, and drop a now-obvious build-order comment in the Makefile. Co-Authored-By: Claude Opus 4.8 (1M context) --- Makefile | 2 -- docs/ReactRouterV8.md | 6 +++--- docs/Routing.md | 2 +- packages/ra-core/src/routing/README.md | 2 +- packages/ra-router-react-router-next/README.md | 4 ++-- .../src/reactRouterNextProvider.tsx | 2 +- packages/ra-router-react-router/README.md | 6 +++--- packages/ra-router-react-router/package.json | 12 ++++++++---- yarn.lock | 2 ++ 9 files changed, 21 insertions(+), 17 deletions(-) diff --git a/Makefile b/Makefile index c02302abca6..6be609614c6 100644 --- a/Makefile +++ b/Makefile @@ -48,8 +48,6 @@ build-offline: ## build the offline example preview-offline: ## preview the offline example @yarn preview-offline -# ra-router-react-router exposes the default react-router v6/v7 provider and is a -# dependency of ra-core, so it must be built before ra-core. build-ra-router-react-router: @echo "Transpiling ra-router-react-router files..."; @cd ./packages/ra-router-react-router && yarn build diff --git a/docs/ReactRouterV8.md b/docs/ReactRouterV8.md index 2da971002a5..3ba4b8c49c1 100644 --- a/docs/ReactRouterV8.md +++ b/docs/ReactRouterV8.md @@ -5,9 +5,9 @@ title: "React Router v8 Integration" # React Router v8 Integration -By default, react-admin is powered by [React Router](https://reactrouter.com/) v6 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`. +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 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.** +**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. @@ -55,5 +55,5 @@ If your application already uses React Router, you can embed react-admin inside ## 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. +- 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/Routing.md b/docs/Routing.md index f0051edd1b2..2fe76c64dca 100644 --- a/docs/Routing.md +++ b/docs/Routing.md @@ -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 (v6) 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. +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: diff --git a/packages/ra-core/src/routing/README.md b/packages/ra-core/src/routing/README.md index d160df01aa7..7fbd841c28e 100644 --- a/packages/ra-core/src/routing/README.md +++ b/packages/ra-core/src/routing/README.md @@ -149,7 +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 | -| `ra-router-react-router` (package) | Default implementation using react-router v6 (`reactRouterProvider`) | +| `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-router-react-router-next/README.md b/packages/ra-router-react-router-next/README.md index 0403f03a990..05b8d21e6f1 100644 --- a/packages/ra-router-react-router-next/README.md +++ b/packages/ra-router-react-router-next/README.md @@ -2,11 +2,11 @@ [React Router v8](https://reactrouter.com) adapter for [react-admin](https://github.com/marmelab/react-admin). -react-admin ships with a React Router v6 adapter by default +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 default is +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.** diff --git a/packages/ra-router-react-router-next/src/reactRouterNextProvider.tsx b/packages/ra-router-react-router-next/src/reactRouterNextProvider.tsx index d6b8c82b365..9e6e0032f5a 100644 --- a/packages/ra-router-react-router-next/src/reactRouterNextProvider.tsx +++ b/packages/ra-router-react-router-next/src/reactRouterNextProvider.tsx @@ -97,7 +97,7 @@ const RouterWrapper = ({ basename, children }: RouterWrapperProps) => { * 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 adapter by default. Pass this + * 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. diff --git a/packages/ra-router-react-router/README.md b/packages/ra-router-react-router/README.md index 94ef5a67e52..56c310c34ae 100644 --- a/packages/ra-router-react-router/README.md +++ b/packages/ra-router-react-router/README.md @@ -1,10 +1,10 @@ # ra-router-react-router -[React Router v6](https://reactrouter.com) adapter for [react-admin](https://github.com/marmelab/react-admin). +[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 out of the box. +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 @@ -39,7 +39,7 @@ 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 +[`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. diff --git a/packages/ra-router-react-router/package.json b/packages/ra-router-react-router/package.json index dddca32ad01..7a63c2e3a89 100644 --- a/packages/ra-router-react-router/package.json +++ b/packages/ra-router-react-router/package.json @@ -1,7 +1,7 @@ { "name": "ra-router-react-router", "version": "5.14.7", - "description": "React Router v6 provider for react-admin (the default router adapter)", + "description": "React Router v6/v7 provider for react-admin (the default router adapter)", "files": [ "*.md", "dist", @@ -22,16 +22,20 @@ "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-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", - "react-router-dom": "^6.28.1" + "react-router": "^6.28.1 || ^7.1.1", + "react-router-dom": "^6.28.1 || ^7.1.1" }, "exports": { ".": { diff --git a/yarn.lock b/yarn.lock index 13e53b811e5..eb001a4e0a1 100644 --- a/yarn.lock +++ b/yarn.lock @@ -20694,6 +20694,8 @@ __metadata: 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 From ff4e5284d9cbd5c39c8fc0da1e7215c99d15e12e Mon Sep 17 00:00:00 2001 From: Shaoyu Meng Date: Wed, 24 Jun 2026 17:34:06 -0700 Subject: [PATCH 18/56] test: Mirror ra-router-tanstack stories for ra-router-react-router-next Rewrite the React Router v8 adapter stories to follow the ra-router-tanstack story set 1:1 (same scenarios, helpers, and sample data), adapting only the embedded-router story to React Router v8's createHashRouter/RouterProvider. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src/reactRouterNextProvider.stories.tsx | 1755 +++++++++++++++-- 1 file changed, 1568 insertions(+), 187 deletions(-) diff --git a/packages/ra-router-react-router-next/src/reactRouterNextProvider.stories.tsx b/packages/ra-router-react-router-next/src/reactRouterNextProvider.stories.tsx index fc5834aee03..58b733ba9fb 100644 --- a/packages/ra-router-react-router-next/src/reactRouterNextProvider.stories.tsx +++ b/packages/ra-router-react-router-next/src/reactRouterNextProvider.stories.tsx @@ -1,29 +1,44 @@ import * as React from 'react'; import fakeDataProvider from 'ra-data-fakerest'; import { - CoreAdmin, - Resource, - CustomRoutes, + createHashRouter, + RouterProvider, + Outlet, + Link as ReactRouterLink, + useNavigate as useReactRouterNavigate, +} from 'react-router'; + +import { + useNavigate, + useLocation, + LinkBase, + useBlocker, ListBase, ShowBase, EditBase, CreateBase, useRecordContext, - useNavigate, - useLocation, - LinkBase, - useBlocker, + CoreAdmin, + Resource, + CustomRoutes, Form, + RouterProviderContext, testUI, } from 'ra-core'; import { reactRouterNextProvider } from './reactRouterNextProvider'; -const { useParams, useMatch, useInRouterContext, Route, Navigate } = - reactRouterNextProvider; +const { + useParams, + useMatch, + useInRouterContext, + useCanBlock, + Route, + Navigate, +} = reactRouterNextProvider; const { TextInput } = testUI; export default { - title: 'ra-routing-react-router-next/React Router v8 Provider', + title: 'ra-routing-react-router-next/React Router Provider', }; const dataProvider = fakeDataProvider( @@ -42,18 +57,6 @@ const dataProvider = fakeDataProvider( process.env.NODE_ENV === 'development' ); -const LocationDisplay = () => { - const location = useLocation(); - return
    {location.pathname}
    ; -}; - -const Layout = ({ children }: { children?: React.ReactNode }) => ( -
    - - {children} -
    -); - const PostList = () => { const navigate = useNavigate(); return ( @@ -82,57 +85,117 @@ const PostList = () => { const PostShow = () => { const navigate = useNavigate(); - const { id } = useParams<{ id: string }>(); return ( - - navigate(`/posts/${id}`)} /> - + ( +
    +

    Post Details

    + {record && ( + <> +
    +
    ID:
    +
    {record.id}
    +
    +
    +
    Title:
    +
    {record.title}
    +
    +
    +
    Body:
    +
    {record.body}
    +
    + + + )} + + +
    + )} + /> ); }; -const PostShowView = ({ onEdit }: { onEdit: () => void }) => { - const record = useRecordContext(); - if (!record) return null; +const PostEdit = () => { + const navigate = useNavigate(); return ( -
    -

    {record.title}

    -

    {record.body}

    - - Back to list -
    + +
    +

    Edit Post

    +
    + + + + + +
    +
    ); }; -const PostEdit = () => { - const { id } = useParams<{ id: string }>(); +const PostCreate = () => { + const navigate = useNavigate(); return ( - -
    - - - -
    + +
    +

    Create Post

    +
    + + + + + +
    +
    + ); +}; + +const LocationDisplay = () => { + const location = useLocation(); + return ( +
    + Current Location: +
    {JSON.stringify(location, null, 2)}
    +
    window.location.hash: {window.location.hash}
    +
    ); }; -const PostCreate = () => ( - -
    - - - -
    +const LayoutWithLocationDisplay = ({ + children, +}: { + children: React.ReactNode; +}) => ( +
    + {children} + +
    ); /** - * BasicStandalone: react-admin runs on its own hash router created by the - * react-router v8 provider (no surrounding router). + * BasicStandalone: Admin creates its own React Router (standalone mode) + * Tests basic navigation, links, and programmatic navigation. */ export const BasicStandalone = () => ( ( ); /** - * MultipleResources: several resources sharing the v8 provider. + * EmbeddedInReactRouter: Admin inside an existing React Router app + * Tests that react-admin detects existing router and uses it. */ -const CommentList = () => ( - ( -
      - {data?.map(record =>
    • {record.body}
    • )} -
    - )} - /> -); +// Nav component that uses the router for navigation +const EmbeddedNav = () => { + const navigate = useReactRouterNavigate(); + return ( + + ); +}; -export const MultipleResources = () => ( +const EmbeddedAdmin = () => ( - - + ); +// A single splat route under /admin handles /admin, /admin/posts, +// /admin/posts/1/show, etc. (react-router resolves nested paths from one route). +const embeddedRouter = createHashRouter([ + { + path: '/', + element: ( +
    + + +
    + ), + children: [ + { + index: true, + element: ( +
    +

    Home Page

    +

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

    + + Go to Admin + +
    + ), + }, + { path: 'admin/*', element: }, + ], + }, +]); + /** - * LinkComponent: navigation through LinkBase (which renders the provider Link). + * Admin inside an existing React Router app + * Tests that react-admin detects existing router and uses it. */ -export const LinkComponent = () => ( - - - +export const EmbeddedInReactRouter = () => ( + ); /** - * NavigateComponent: declarative redirect through the provider Navigate. + * Tests back/forward navigation */ -const RedirectToPosts = () => ; +export const HistoryNavigation = () => { + const HistoryButtons = () => { + const navigate = useNavigate(); + return ( +
    + + +
    + ); + }; -export const NavigateComponent = () => ( - - - } /> - - - -); + const ListWithHistory = () => ( +
    + + +
    + ); + + const ShowWithHistory = () => ( +
    + + +
    + ); + + return ( + + } + show={} + /> + + ); +}; /** - * CustomRoutesSupport: a custom page rendered through CustomRoutes. + * Tests that routes match correctly + * Tests resource routes, custom routes, and catch-all routes. */ -const CustomPage = () => { - const location = useLocation(); +export const RouteMatching = () => { + const Dashboard = () => ( +
    +

    Dashboard

    +

    Welcome to the admin dashboard.

    +
      +
    • + Posts +
    • +
    +
    + ); + return ( -
    Custom page at {location.pathname}
    + + + ); }; -export const CustomRoutesSupport = () => ( - - - } /> - - - -); +/** + * 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 ( + + + + ); +}; /** - * UseParamsTest: reads the record id from the URL params. + * Tests navigation between multiple resources */ -const ParamsReader = () => { - const params = useParams(); - return
    {JSON.stringify(params)}
    ; +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 UseParamsTest = () => ( - - - } /> - - - -); +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) + +
    + + } + /> +
    + ); +}; /** - * UseMatchTest: matches the current location against a pattern. + * Displays URL parameters extracted from the current route. */ -const MatchReader = () => { - const match = useMatch({ path: '/posts/:id/show' }); - return
    {JSON.stringify(match)}
    ; +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={} + /> +
    + ); }; -export const UseMatchTest = () => ( - - - } /> - - - -); +/** + * 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

    +
    + + } + /> +
    + ); +}; /** - * UseLocationTest: surfaces the current location. + * Blocks navigation when there are unsaved changes. */ -export const UseLocationTest = () => ( - - - -); +export const UseBlockerTest = () => { + const FormWithBlocker = () => { + const [isDirty, setIsDirty] = React.useState(false); + const [inputValue, setInputValue] = React.useState(''); + + const blocker = useBlocker( + ({ currentLocation, nextLocation }) => + isDirty && currentLocation.pathname !== nextLocation.pathname + ); + + return ( +
    +

    Form with Unsaved Changes Warning

    +
    + +
    +
    + + {isDirty ? 'Unsaved changes' : 'No changes'} + +
    +
    + +
    +
    + Go to Comments +
    + {blocker.state === 'blocked' && ( +
    +
    +

    Unsaved Changes

    +

    + You have unsaved changes. Are you sure you want + to leave? +

    + + +
    +
    + )} +
    + Blocker State:{' '} + {blocker.state} +
    +
    + ); + }; + + 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: confirms react-admin detects it is inside a router. + * RouterContextTest: Tests useInRouterContext and useCanBlock hooks */ -const InRouterContextReader = () => { - const inContext = useInRouterContext(); - return
    {String(inContext)}
    ; +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

    + + + } + /> +
    + ); }; -export const RouterContextTest = () => ( +const { Routes, Outlet: RouterOutlet } = reactRouterNextProvider; + +export const NestedResources = () => ( - - } /> - - + }> + } /> + ); -/** - * UseBlockerTest: blocks navigation while a form is dirty. - */ -const BlockerForm = () => { - const [dirty, setDirty] = React.useState(false); - const blocker = useBlocker(dirty); +const PostEditWithLinkToComments = () => { const navigate = useNavigate(); return ( -
    - - - {blocker.state === 'blocked' ? ( -
    - - + ( +
    +

    Post Details

    + {record &&

    {record.title}

    } + +
    - ) : null} -
    + )} + /> ); }; -export const UseBlockerTest = () => ( +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 + + } + /> + +
    + +
    +
    + ); +}; From ba206386a3d548ff1d058cc750779bd844460b27 Mon Sep 17 00:00:00 2001 From: Shaoyu Meng Date: Wed, 24 Jun 2026 17:39:13 -0700 Subject: [PATCH 19/56] update react-router provider comments --- .../src/reactRouterProvider.tsx | 13 ++++--------- 1 file changed, 4 insertions(+), 9 deletions(-) diff --git a/packages/ra-router-react-router/src/reactRouterProvider.tsx b/packages/ra-router-react-router/src/reactRouterProvider.tsx index 1ad859e4205..3d68f76aeb5 100644 --- a/packages/ra-router-react-router/src/reactRouterProvider.tsx +++ b/packages/ra-router-react-router/src/reactRouterProvider.tsx @@ -19,12 +19,10 @@ import { } from 'react-router'; import { Link, createHashRouter } from 'react-router-dom'; -// This adapter is the default RouterProvider implementation, and ra-core depends -// on it to expose `reactRouterProvider`. To avoid a circular build dependency -// (ra-core -> ra-router-react-router -> ra-core), this package deliberately does -// NOT import ra-core's `RouterProvider` types. Instead it relies on local aliases -// that mirror ra-core's contract; conformance to the `RouterProvider` interface is -// enforced where ra-core consumes `reactRouterProvider`. +// 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 } @@ -124,9 +122,6 @@ const RouterWrapper = ({ basename, children }: RouterWrapperProps) => { /** * Default router provider using react-router-dom. * This provider is used by default when no custom routerProvider is provided to . - * - * It implements ra-core's `RouterProvider` interface (the conformance check happens - * in ra-core, which consumes this object as its default provider). */ export const reactRouterProvider = { // Hooks From 689e5dd19df14c35dab350ed3421d140b764fbfd Mon Sep 17 00:00:00 2001 From: Shaoyu Meng Date: Wed, 24 Jun 2026 18:51:25 -0700 Subject: [PATCH 20/56] test: Add unit tests for the React Router v8 adapter React Router v8 is ESM-only and requires React 19, neither of which fits the default CommonJS jest project. Add a dedicated jest project for ra-router-react-router-next (its own jest.config.cjs, wired into the root config via `projects`): it runs in ESM mode, transforms react-router, and forces React 19 (a new devDependency of the package) as a single instance across the tree to avoid duplicate-React hook errors. The test scripts now pass `NODE_OPTIONS=--experimental-vm-modules`. The new spec mirrors the tanstack adapter tests: matchPath (including basename scenarios), RouterWrapper standalone/embedded with a basename, and the routing hooks/components exercised through the stories. Co-Authored-By: Claude Opus 4.8 (1M context) --- jest.config.js | 55 +-- package.json | 4 +- .../jest.config.cjs | 68 +++ .../ra-router-react-router-next/package.json | 2 + .../src/reactRouterNextProvider.spec.tsx | 388 ++++++++++++++++++ yarn.lock | 27 ++ 6 files changed, 519 insertions(+), 25 deletions(-) create mode 100644 packages/ra-router-react-router-next/jest.config.cjs create mode 100644 packages/ra-router-react-router-next/src/reactRouterNextProvider.spec.tsx diff --git a/jest.config.js b/jest.config.js index 7e69b06b311..ca2732b8a72 100644 --- a/jest.config.js +++ b/jest.config.js @@ -14,29 +14,38 @@ const moduleNameMapper = packages.reduce((mapper, dirName) => { }, {}); module.exports = { - globalSetup: './test-global-setup.js', - setupFilesAfterEnv: ['./test-setup.js'], - testEnvironment: 'jsdom', - testPathIgnorePatterns: [ - '/node_modules/', - '/lib/', - '/esm/', - '/examples/simple/', - '/packages/create-react-admin/templates', - ], - transformIgnorePatterns: [ - '[/\\\\]node_modules[/\\\\](?!(@hookform|react-hotkeys-hook|@faker-js/faker)/).+\\.(js|jsx|mjs|ts|tsx)$', - ], - transform: { - // '^.+\\.[tj]sx?$' to process js/ts with `ts-jest` - '^.+\\.[tj]sx?$': [ - 'ts-jest', - { - isolatedModules: true, - useESM: true, + projects: [ + { + globalSetup: './test-global-setup.js', + setupFilesAfterEnv: ['./test-setup.js'], + testEnvironment: 'jsdom', + testPathIgnorePatterns: [ + '/node_modules/', + '/lib/', + '/esm/', + '/examples/simple/', + '/packages/create-react-admin/templates', + '/packages/ra-router-react-router-next/', + ], + transformIgnorePatterns: [ + '[/\\\\]node_modules[/\\\\](?!(@hookform|react-hotkeys-hook|@faker-js/faker)/).+\\.(js|jsx|mjs|ts|tsx)$', + ], + transform: { + // '^.+\\.[tj]sx?$' to process js/ts with `ts-jest` + '^.+\\.[tj]sx?$': [ + 'ts-jest', + { + isolatedModules: true, + useESM: true, + }, + ], }, - ], - }, - moduleNameMapper, + moduleNameMapper, + }, + // ra-router-react-router-next supplies its own (ESM, React 19) project + // config. Running its tests requires `NODE_OPTIONS=--experimental-vm-modules` + // (set in the test scripts). + './packages/ra-router-react-router-next/jest.config.cjs', + ], testTimeout: 60000, }; diff --git a/package.json b/package.json index 37f24c146fe..8b76207d0dc 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 NODE_OPTIONS=--experimental-vm-modules 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 NODE_OPTIONS=--experimental-vm-modules jest --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-router-react-router-next/jest.config.cjs b/packages/ra-router-react-router-next/jest.config.cjs new file mode 100644 index 00000000000..54e036669c7 --- /dev/null +++ b/packages/ra-router-react-router-next/jest.config.cjs @@ -0,0 +1,68 @@ +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 = { + displayName: 'react-router-v8', + 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 index 3f16867fd3a..e943d54d1b5 100644 --- a/packages/ra-router-react-router-next/package.json +++ b/packages/ra-router-react-router-next/package.json @@ -26,6 +26,8 @@ }, "devDependencies": { "ra-core": "^5.14.7", + "react": "^19.2.7", + "react-dom": "^19.2.7", "react-router": "^8.0.0", "typescript": "^5.1.3", "zshy": "^0.5.0" 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..1c1b5eac465 --- /dev/null +++ b/packages/ra-router-react-router-next/src/reactRouterNextProvider.spec.tsx @@ -0,0 +1,388 @@ +import * as React from 'react'; +import { render, screen, waitFor, cleanup } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { + BasicStandalone, + EmbeddedInReactRouter, + HistoryNavigation, + LinkComponent, + MultipleResources, + CustomRoutesSupport, + UseParamsTest, + UseMatchTest, + UseBlockerTest, + NavigateComponent, + UseLocationTest, + RouterContextTest, + NestedRoutesWithOutlet, + NestedResources, + QueryParameters, + PathlessLayoutRoutes, +} from './reactRouterNextProvider.stories'; +import { reactRouterNextProvider } from './reactRouterNextProvider'; + +const { matchPath } = reactRouterNextProvider; + +describe('reactRouterNextProvider', () => { + 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 a nested path', () => { + expect(matchPath('/*', '/posts/1')).toMatchObject({ + params: { '*': 'posts/1' }, + pathname: '/posts/1', + pathnameBase: '/', + }); + }); + }); + + describe('root/empty paths', () => { + it('should match "/" against "/"', () => { + expect(matchPath('/', '/')).toMatchObject({ + params: {}, + pathname: '/', + pathnameBase: '/', + }); + }); + + it('should not match "/" against "/posts" by default (end=true)', () => { + expect(matchPath('/', '/posts')).toBeNull(); + }); + }); + + describe('static paths', () => { + it('should match an exact static path', () => { + expect(matchPath('/posts', '/posts')).toMatchObject({ + params: {}, + pathname: '/posts', + pathnameBase: '/posts', + }); + }); + + it('should not match a static path against a longer path', () => { + expect(matchPath('/posts', '/posts/1')).toBeNull(); + }); + + it('should match a static path as a prefix with end=false', () => { + expect( + matchPath({ path: '/posts', end: false }, '/posts/1') + ).toMatchObject({ + params: {}, + pathname: '/posts', + }); + }); + }); + + describe('dynamic params', () => { + it('should match a single param', () => { + expect(matchPath('/posts/:id', '/posts/1')).toMatchObject({ + params: { id: '1' }, + pathname: '/posts/1', + }); + }); + + it('should match multiple params', () => { + expect( + matchPath('/:resource/:id/show', '/posts/1/show') + ).toMatchObject({ + params: { resource: 'posts', id: '1' }, + pathname: '/posts/1/show', + }); + }); + + it('should not match a param when the segment is missing', () => { + expect(matchPath('/posts/:id', '/posts')).toBeNull(); + }); + }); + + describe('react-admin resource patterns', () => { + it('should match a resource list', () => { + expect(matchPath('/:resource', '/posts')).toMatchObject({ + params: { resource: 'posts' }, + }); + }); + + it('should match a resource edit', () => { + expect(matchPath('/:resource/:id', '/posts/1')).toMatchObject({ + params: { resource: 'posts', id: '1' }, + }); + }); + }); + + describe('basename scenarios (pathname already stripped of basename)', () => { + it('should match a path after the basename is stripped', () => { + // basename "/admin" + "/admin/posts" => matchPath sees "/posts" + expect(matchPath('/posts', '/posts')).toMatchObject({ + params: {}, + pathname: '/posts', + }); + }); + + it('should match a catch-all after the basename is stripped', () => { + // "/admin/posts/1" with basename "/admin" => "/posts/1" + expect(matchPath('/*', '/posts/1')).toMatchObject({ + params: { '*': 'posts/1' }, + }); + }); + + it('should match a nested resource after the basename is stripped', () => { + // "/admin/posts/1/show" with basename "/admin" => "/posts/1/show" + expect( + matchPath('/:resource/:id/show', '/posts/1/show') + ).toMatchObject({ + params: { resource: 'posts', id: '1' }, + }); + }); + }); + }); + + describe('RouterWrapper', () => { + describe('standalone mode', () => { + it('should render the post list inside its own hash router', 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 (with basename)', () => { + it('should render the host app 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 mount react-admin under the basename and navigate to it', 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 } + ); + }); + }); + }); + + describe('useNavigate', () => { + it('should navigate programmatically', async () => { + const user = userEvent.setup(); + render(); + await waitFor(() => { + expect(screen.getByText('Posts')).toBeInTheDocument(); + }); + await user.click(screen.getByText('Post #1')); + await waitFor(() => { + expect(screen.getByText('Post Details')).toBeInTheDocument(); + }); + }); + }); + + describe('Link', () => { + it('should render links and navigate on click', async () => { + const user = userEvent.setup(); + render(); + await waitFor(() => { + expect(screen.getByText('Go to Post #1')).toBeInTheDocument(); + }); + await user.click(screen.getByText('Go to Post #1')); + await waitFor(() => { + expect(screen.getByText('Post Details')).toBeInTheDocument(); + }); + }); + }); + + describe('Routes / multiple resources', () => { + it('should render multiple resources and navigate between them', async () => { + const user = userEvent.setup(); + render(); + await waitFor(() => { + expect(screen.getByText('Go to Comments')).toBeInTheDocument(); + }); + await user.click(screen.getByText('Go to Comments')); + await waitFor(() => { + expect(screen.getByText('Comments')).toBeInTheDocument(); + }); + }); + }); + + describe('custom routes', () => { + it('should render a custom route', async () => { + render(); + await waitFor(() => { + expect( + screen.getByText('Go to Custom Page') + ).toBeInTheDocument(); + }); + }); + }); + + describe('useParams', () => { + it('should expose the URL params', async () => { + const user = userEvent.setup(); + render(); + await waitFor(() => { + expect(screen.getByText('Post #1')).toBeInTheDocument(); + }); + await user.click(screen.getByText('Post #1')); + await waitFor(() => { + const params = screen.getAllByTestId('params-display'); + expect( + params.some(p => (p.textContent || '').includes('"id"')) + ).toBe(true); + }); + }); + }); + + describe('useMatch', () => { + it('should report a match for the current location', async () => { + render(); + await waitFor(() => { + expect(screen.getByTestId('posts-match')).toHaveTextContent( + 'MATCH' + ); + }); + }); + }); + + describe('Navigate', () => { + it('should redirect declaratively', async () => { + const user = userEvent.setup(); + render(); + await waitFor(() => { + expect(screen.getByTestId('posts-page')).toBeInTheDocument(); + }); + await user.click( + screen.getByText('Go to Redirect Page (auto-redirects here)') + ); + await waitFor(() => { + expect(screen.getByTestId('posts-page')).toBeInTheDocument(); + }); + }); + }); + + describe('useLocation', () => { + it('should expose the current location', async () => { + render(); + await waitFor(() => { + expect( + screen.getByTestId('location-pathname') + ).toBeInTheDocument(); + }); + }); + }); + + describe('useInRouterContext / useCanBlock', () => { + it('should report being inside a router', async () => { + render(); + await waitFor(() => { + expect( + screen.getByTestId('in-router-context') + ).toHaveTextContent('true'); + }); + }); + }); + + describe('useBlocker', () => { + it('should block navigation when there are unsaved changes', async () => { + const user = userEvent.setup(); + render(); + await waitFor(() => { + expect(screen.getByTestId('form-input')).toBeInTheDocument(); + }); + await user.type(screen.getByTestId('form-input'), 'dirty'); + await user.click(screen.getByText('Go to Comments')); + await waitFor(() => { + expect( + screen.getByTestId('blocker-dialog') + ).toBeInTheDocument(); + }); + }); + }); + + describe('nested routes with Outlet', () => { + it('should render the default tab', async () => { + render(); + await waitFor(() => { + expect(screen.getByText('Posts')).toBeInTheDocument(); + }); + }); + }); + + describe('nested resources', () => { + it('should render a resource with nested route children', async () => { + render(); + await waitFor(() => { + expect(screen.getByText('Posts')).toBeInTheDocument(); + }); + }); + }); + + describe('query parameters', () => { + it('should update the search part of the location', async () => { + const user = userEvent.setup(); + render(); + await waitFor(() => { + expect(screen.getByTestId('sort-title')).toBeInTheDocument(); + }); + await user.click(screen.getByTestId('sort-title')); + await waitFor(() => { + expect(screen.getByTestId('current-sort')).toHaveTextContent( + 'title' + ); + }); + }); + }); + + describe('pathless layout routes', () => { + it('should render the layout wrapper and matched child', async () => { + window.location.hash = '#/posts'; + render(); + await waitFor(() => { + expect( + screen.getByTestId('layout-wrapper') + ).toBeInTheDocument(); + expect(screen.getByTestId('posts-page')).toBeInTheDocument(); + }); + }); + }); +}); diff --git a/yarn.lock b/yarn.lock index eb001a4e0a1..f465eb2c39a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -20672,6 +20672,8 @@ __metadata: resolution: "ra-router-react-router-next@workspace:packages/ra-router-react-router-next" dependencies: ra-core: "npm:^5.14.7" + 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" @@ -21027,6 +21029,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" @@ -21288,6 +21301,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" @@ -22361,6 +22381,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" From 84ea0f51d05dfb8734e8e1e66524607f4b59913d Mon Sep 17 00:00:00 2001 From: Shaoyu Meng Date: Wed, 24 Jun 2026 19:00:12 -0700 Subject: [PATCH 21/56] test: Apply review feedback to React Router v8 adapter stories Address the PR review comments on the stories: - use test-ui components (SimpleList, SimpleShowLayout, SimpleForm, CreateButton) instead of hand-rolled markup - rename the basic story to `Basic` (ra convention) - test Link and the routing hooks (useParams/useMatch/useLocation/ useInRouterContext/useCanBlock) through CustomRoutes with no resource - drop the redundant stories (the duplicate "basic" variant and the redirect-to-first-resource story, which is already react-admin's default) - let EditBase read the route params instead of reading them manually - use the built-in useWarnWhenUnsavedChanges (via Form's warnWhenUnsavedChanges) instead of reimplementing navigation blocking The spec is updated to match the reworked stories. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src/reactRouterNextProvider.spec.tsx | 251 +-- .../src/reactRouterNextProvider.stories.tsx | 1721 ++--------------- 2 files changed, 271 insertions(+), 1701 deletions(-) diff --git a/packages/ra-router-react-router-next/src/reactRouterNextProvider.spec.tsx b/packages/ra-router-react-router-next/src/reactRouterNextProvider.spec.tsx index 1c1b5eac465..476086fb705 100644 --- a/packages/ra-router-react-router-next/src/reactRouterNextProvider.spec.tsx +++ b/packages/ra-router-react-router-next/src/reactRouterNextProvider.spec.tsx @@ -2,22 +2,15 @@ import * as React from 'react'; import { render, screen, waitFor, cleanup } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { - BasicStandalone, - EmbeddedInReactRouter, - HistoryNavigation, + Basic, + Embedded, LinkComponent, - MultipleResources, - CustomRoutesSupport, - UseParamsTest, - UseMatchTest, - UseBlockerTest, NavigateComponent, - UseLocationTest, - RouterContextTest, - NestedRoutesWithOutlet, - NestedResources, - QueryParameters, - PathlessLayoutRoutes, + UseParams, + UseMatch, + UseLocation, + RouterContext, + WarnWhenUnsavedChanges, } from './reactRouterNextProvider.stories'; import { reactRouterNextProvider } from './reactRouterNextProvider'; @@ -152,63 +145,19 @@ describe('reactRouterNextProvider', () => { }); }); - describe('RouterWrapper', () => { - describe('standalone mode', () => { - it('should render the post list inside its own hash router', 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 (with basename)', () => { - it('should render the host app 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 mount react-admin under the basename and navigate to it', 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 } - ); + describe('RouterWrapper standalone mode (Basic)', () => { + it('should render the post list inside its own hash router', async () => { + render(); + await waitFor(() => { + expect(screen.getByText('Posts')).toBeInTheDocument(); }); }); - }); - describe('useNavigate', () => { - it('should navigate programmatically', async () => { + it('should navigate from the list to the show view via a Link', async () => { const user = userEvent.setup(); - render(); + render(); await waitFor(() => { - expect(screen.getByText('Posts')).toBeInTheDocument(); + expect(screen.getByText('Post #1')).toBeInTheDocument(); }); await user.click(screen.getByText('Post #1')); await waitFor(() => { @@ -217,95 +166,77 @@ describe('reactRouterNextProvider', () => { }); }); - describe('Link', () => { - it('should render links and navigate on click', async () => { - const user = userEvent.setup(); - render(); + describe('RouterWrapper embedded mode with basename (Embedded)', () => { + it('should render the host app home page initially', async () => { + render(); await waitFor(() => { - expect(screen.getByText('Go to Post #1')).toBeInTheDocument(); + expect(screen.getByText('Home Page')).toBeInTheDocument(); }); - await user.click(screen.getByText('Go to Post #1')); + }); + + it('should mount react-admin under the basename and navigate to it', async () => { + const user = userEvent.setup(); + render(); await waitFor(() => { - expect(screen.getByText('Post Details')).toBeInTheDocument(); + expect(screen.getByText('Admin')).toBeInTheDocument(); }); + await user.click(screen.getByText('Admin')); + await waitFor( + () => { + expect(screen.getByText('Posts')).toBeInTheDocument(); + }, + { timeout: 3000 } + ); }); }); - describe('Routes / multiple resources', () => { - it('should render multiple resources and navigate between them', async () => { + describe('Link (custom routes, no resource)', () => { + it('should navigate on click', async () => { const user = userEvent.setup(); - render(); + render(); await waitFor(() => { - expect(screen.getByText('Go to Comments')).toBeInTheDocument(); + expect(screen.getByText('Go to page 2')).toBeInTheDocument(); }); - await user.click(screen.getByText('Go to Comments')); + await user.click(screen.getByText('Go to page 2')); await waitFor(() => { - expect(screen.getByText('Comments')).toBeInTheDocument(); + expect(screen.getByTestId('page-2')).toBeInTheDocument(); }); }); }); - describe('custom routes', () => { - it('should render a custom route', async () => { - render(); + describe('Navigate (declarative redirect)', () => { + it('should redirect to the target route', async () => { + render(); await waitFor(() => { - expect( - screen.getByText('Go to Custom Page') - ).toBeInTheDocument(); + expect(screen.getByTestId('target-page')).toBeInTheDocument(); }); }); }); describe('useParams', () => { it('should expose the URL params', async () => { - const user = userEvent.setup(); - render(); + render(); await waitFor(() => { - expect(screen.getByText('Post #1')).toBeInTheDocument(); - }); - await user.click(screen.getByText('Post #1')); - await waitFor(() => { - const params = screen.getAllByTestId('params-display'); - expect( - params.some(p => (p.textContent || '').includes('"id"')) - ).toBe(true); + expect(screen.getByTestId('params')).toHaveTextContent('"42"'); }); }); }); describe('useMatch', () => { - it('should report a match for the current location', async () => { - render(); + it('should match the current location against a pattern', async () => { + render(); await waitFor(() => { - expect(screen.getByTestId('posts-match')).toHaveTextContent( - 'MATCH' - ); - }); - }); - }); - - describe('Navigate', () => { - it('should redirect declaratively', async () => { - const user = userEvent.setup(); - render(); - await waitFor(() => { - expect(screen.getByTestId('posts-page')).toBeInTheDocument(); - }); - await user.click( - screen.getByText('Go to Redirect Page (auto-redirects here)') - ); - await waitFor(() => { - expect(screen.getByTestId('posts-page')).toBeInTheDocument(); + expect(screen.getByTestId('match')).toHaveTextContent('"7"'); }); }); }); describe('useLocation', () => { it('should expose the current location', async () => { - render(); + render(); await waitFor(() => { expect( - screen.getByTestId('location-pathname') + screen.getByTestId('location-display') ).toBeInTheDocument(); }); }); @@ -313,7 +244,7 @@ describe('reactRouterNextProvider', () => { describe('useInRouterContext / useCanBlock', () => { it('should report being inside a router', async () => { - render(); + render(); await waitFor(() => { expect( screen.getByTestId('in-router-context') @@ -322,67 +253,31 @@ describe('reactRouterNextProvider', () => { }); }); - describe('useBlocker', () => { - it('should block navigation when there are unsaved changes', async () => { - const user = userEvent.setup(); - render(); - await waitFor(() => { - expect(screen.getByTestId('form-input')).toBeInTheDocument(); - }); - await user.type(screen.getByTestId('form-input'), 'dirty'); - await user.click(screen.getByText('Go to Comments')); - await waitFor(() => { - expect( - screen.getByTestId('blocker-dialog') - ).toBeInTheDocument(); - }); - }); - }); - - describe('nested routes with Outlet', () => { - it('should render the default tab', async () => { - render(); - await waitFor(() => { - expect(screen.getByText('Posts')).toBeInTheDocument(); - }); - }); - }); - - describe('nested resources', () => { - it('should render a resource with nested route children', async () => { - render(); - await waitFor(() => { - expect(screen.getByText('Posts')).toBeInTheDocument(); - }); - }); - }); - - describe('query parameters', () => { - it('should update the search part of the location', async () => { - const user = userEvent.setup(); - render(); - await waitFor(() => { - expect(screen.getByTestId('sort-title')).toBeInTheDocument(); - }); - await user.click(screen.getByTestId('sort-title')); - await waitFor(() => { - expect(screen.getByTestId('current-sort')).toHaveTextContent( - 'title' - ); - }); - }); - }); - - describe('pathless layout routes', () => { - it('should render the layout wrapper and matched child', async () => { - window.location.hash = '#/posts'; - render(); - await waitFor(() => { + describe('useWarnWhenUnsavedChanges', () => { + it('should confirm before navigating away from a dirty form', async () => { + const originalConfirm = window.confirm; + let confirmCalled = false; + // Decline the confirmation so navigation stays blocked. + window.confirm = () => { + confirmCalled = true; + return false; + }; + try { + const user = userEvent.setup(); + render(); + const title = await screen.findByDisplayValue('Post #1'); + await user.type(title, ' edited'); + await user.click(screen.getByText('Go to comments')); + await waitFor(() => { + expect(confirmCalled).toBe(true); + }); + // confirm returned false => navigation blocked, still on the form expect( - screen.getByTestId('layout-wrapper') + screen.getByDisplayValue('Post #1 edited') ).toBeInTheDocument(); - expect(screen.getByTestId('posts-page')).toBeInTheDocument(); - }); + } finally { + window.confirm = originalConfirm; + } }); }); }); diff --git a/packages/ra-router-react-router-next/src/reactRouterNextProvider.stories.tsx b/packages/ra-router-react-router-next/src/reactRouterNextProvider.stories.tsx index 58b733ba9fb..e27e12499cf 100644 --- a/packages/ra-router-react-router-next/src/reactRouterNextProvider.stories.tsx +++ b/packages/ra-router-react-router-next/src/reactRouterNextProvider.stories.tsx @@ -7,22 +7,17 @@ import { Link as ReactRouterLink, useNavigate as useReactRouterNavigate, } from 'react-router'; - import { - useNavigate, - useLocation, - LinkBase, - useBlocker, + CoreAdmin, + Resource, + CustomRoutes, ListBase, ShowBase, EditBase, CreateBase, useRecordContext, - CoreAdmin, - Resource, - CustomRoutes, - Form, - RouterProviderContext, + useLocation, + LinkBase, testUI, } from 'ra-core'; import { reactRouterNextProvider } from './reactRouterNextProvider'; @@ -35,7 +30,8 @@ const { Route, Navigate, } = reactRouterNextProvider; -const { TextInput } = testUI; +const { TextInput, SimpleList, SimpleShowLayout, SimpleForm, CreateButton } = + testUI; export default { title: 'ra-routing-react-router-next/React Router Provider', @@ -57,111 +53,69 @@ const dataProvider = fakeDataProvider( process.env.NODE_ENV === 'development' ); -const PostList = () => { - const navigate = useNavigate(); - return ( - ( -
    -

    Posts

    -
      - {data?.map(record => ( -
    • - - {record.title} - -
    • - ))} -
    - -
    - )} - /> - ); +const Field = ({ source }: { source: string }) => { + const record = useRecordContext(); + return {record?.[source]}; }; -const PostShow = () => { - const navigate = useNavigate(); - return ( - ( -
    -

    Post Details

    - {record && ( - <> -
    -
    ID:
    -
    {record.id}
    -
    -
    -
    Title:
    -
    {record.title}
    -
    -
    -
    Body:
    -
    {record.body}
    -
    - - - )} - - -
    - )} - /> - ); -}; +const PostList = () => ( + +
    +

    Posts

    + + ( + + {record.title} + + )} + /> +
    +
    +); -const PostEdit = () => { - const navigate = useNavigate(); - return ( - -
    -

    Edit Post

    -
    - - - - - -
    -
    - ); -}; +const PostShow = () => ( + +
    +

    Post Details

    + + + + + Back to list +
    +
    +); -const PostCreate = () => { - const navigate = useNavigate(); - return ( - -
    -

    Create Post

    -
    - - - - - -
    -
    - ); -}; +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; -}) => ( +const Layout = ({ children }: { children?: React.ReactNode }) => (
    {children} @@ -188,14 +137,14 @@ const LayoutWithLocationDisplay = ({ ); /** - * BasicStandalone: Admin creates its own React Router (standalone mode) - * Tests basic navigation, links, and programmatic navigation. + * The most basic setup: react-admin runs on its own hash router created by the + * provider, with a single resource exercising list/show/edit/create. */ -export const BasicStandalone = () => ( +export const Basic = () => ( ( ); /** - * EmbeddedInReactRouter: Admin inside an existing React Router app - * Tests that react-admin detects existing router and uses it. + * react-admin embedded inside an existing React Router app, mounted under a + * basename. Exercises the provider detecting the surrounding router and the + * basename support. */ -// Nav component that uses the router for navigation const EmbeddedNav = () => { const navigate = useReactRouterNavigate(); return ( @@ -219,7 +168,6 @@ const EmbeddedNav = () => { Home - {/* Link to /admin/posts to trigger react-admin's routing */} ( routerProvider={reactRouterNextProvider} dataProvider={dataProvider} basename="/admin" - layout={LayoutWithLocationDisplay} + layout={Layout} > - + ); -// A single splat route under /admin handles /admin, /admin/posts, -// /admin/posts/1/show, etc. (react-router resolves nested paths from one route). const embeddedRouter = createHashRouter([ { path: '/', @@ -282,1441 +222,176 @@ const embeddedRouter = createHashRouter([ }, ]); -/** - * Admin inside an existing React Router app - * Tests that react-admin detects existing router and uses it. - */ -export const EmbeddedInReactRouter = () => ( - -); - -/** - * Tests back/forward navigation - */ -export const HistoryNavigation = () => { - const HistoryButtons = () => { - const navigate = useNavigate(); - return ( -
    - - -
    - ); - }; - - const ListWithHistory = () => ( -
    - - -
    - ); - - const ShowWithHistory = () => ( -
    - - -
    - ); - - return ( - - } - show={} - /> - - ); -}; +export const Embedded = () => ; /** - * Tests that routes match correctly - * Tests resource routes, custom routes, and catch-all routes. + * Tests the provider Link (through LinkBase) using custom routes and no resource. */ -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) - +const LinkPage = () => ( +
    +

    Link Component

    + Go to page 2 + + Go to page 2 with search + + + Go to page 2 with state + +
    +); -

    Link with no pathname change

    - - Go to same page with search param - -
    - ); +const Page2 = () => ( +
    +

    Page 2

    + Back + +
    +); - return ( - - - - ); -}; +export const LinkComponent = () => ( + + + } /> + } /> + + +); /** - * Tests navigation between multiple resources + * Tests the provider Navigate (declarative redirect) using custom routes. */ -export const MultipleResources = () => { - const CommentList = () => ( -
    -

    Comments

    -
      -
    • Comment #1: Nice post!
    • -
    • Comment #2: Great article
    • -
    - Go to Posts -
    - ); +const RedirectPage = () => ; - 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 - -
    - ); +const TargetPage = () => ( +
    + Target page +
    +); - return ( - - - } /> - - - } - /> - - - -
    - Go to Custom Page -
    - - Go to Custom Page (No Layout) - -
    - - } - /> -
    - ); -}; +export const NavigateComponent = () => ( + + + } /> + } /> + + +); /** - * Displays URL parameters extracted from the current route. + * Tests the useParams hook using a custom 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={} - /> -
    - ); +const ParamsReader = () => { + const params = useParams(); + return
    {JSON.stringify(params)}
    ; }; -/** - * 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

    -
    - - } - /> -
    - ); -}; +export const UseParams = () => ( + + + } /> + } /> + + +); /** - * Blocks navigation when there are unsaved changes. + * Tests the useMatch hook using a custom route. */ -export const UseBlockerTest = () => { - const FormWithBlocker = () => { - const [isDirty, setIsDirty] = React.useState(false); - const [inputValue, setInputValue] = React.useState(''); - - const blocker = useBlocker( - ({ currentLocation, nextLocation }) => - isDirty && currentLocation.pathname !== nextLocation.pathname - ); - - return ( -
    -

    Form with Unsaved Changes Warning

    -
    - -
    -
    - - {isDirty ? 'Unsaved changes' : 'No changes'} - -
    -
    - -
    -
    - Go to Comments -
    - {blocker.state === 'blocked' && ( -
    -
    -

    Unsaved Changes

    -

    - You have unsaved changes. Are you sure you want - to leave? -

    - - -
    -
    - )} -
    - Blocker State:{' '} - {blocker.state} -
    -
    - ); - }; - - 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). -

    -
    - ); - }; - +const MatchReader = () => { + const match = useMatch({ path: '/posts/:id/show' }); 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 - -
    • -
    - - } - /> -
    +
    {JSON.stringify(match?.params ?? null)}
    ); }; -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 -
    - } - /> -
    - ); -}; +export const UseMatch = () => ( + + + } /> + } /> + + +); /** - * RouterContextTest: Tests useInRouterContext and useCanBlock hooks + * Tests the useLocation hook using a custom route. */ -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 = () => ( +export const UseLocation = () => ( - }> - } /> - + + } /> + ); -const PostEditWithLinkToComments = () => { - const navigate = useNavigate(); - return ( - ( -
    -

    Post Details

    - {record &&

    {record.title}

    } - - -
    - )} - /> - ); -}; - -const CommentList = () => { - const { post_id } = useParams(); - const navigate = useNavigate(); +/** + * Tests the useInRouterContext and useCanBlock hooks using a custom route. + */ +const ContextInfo = () => { + const isInRouter = useInRouterContext(); + const canBlock = useCanBlock(); return ( - ( -
    -

    Comments for Post {post_id}

    -
      - {data?.map(record => ( -
    • {record.body}
    • - ))} -
    - -
    - )} - /> +
    +
    {String(isInRouter)}
    +
    {String(canBlock)}
    +
    ); }; -export const NestedResourcesPrecedence = () => ( +export const RouterContext = () => ( - - } /> - + + } /> + ); /** - * 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). + * Tests navigation blocking through react-admin's built-in + * useWarnWhenUnsavedChanges (via the Form `warnWhenUnsavedChanges` prop), rather + * than a hand-rolled blocker. When the form is dirty, leaving triggers a + * `window.confirm` before navigating away. */ -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; +const PostEditWithWarning = () => ( + +
    +

    Edit Post

    + + + + + Go to comments +
    +
    +); - return ( - - - - - Catch-all Page - - } - /> - -

    Layout Wrapper

    - - -
    - -
    - - } - > - - Home Page (index) - - } - /> - Posts Page - } - /> - - Comments Page - - } - /> - -
    - -
    -
    - ); -}; +export const WarnWhenUnsavedChanges = () => ( + + +

    Comments

    } /> + + } /> + +
    +); From f8f4b699949c55f1dd48f231494649ae0b0b8d8a Mon Sep 17 00:00:00 2001 From: Shaoyu Meng Date: Wed, 24 Jun 2026 22:56:25 -0700 Subject: [PATCH 22/56] test: Mirror ra-router-tanstack stories and tests for ra-router-react-router-next Port the full set of routing stories from ra-router-tanstack to the React Router v8 adapter so coverage matches across both providers, and update the spec to exercise every story. The embedded-router story now mirrors tanstack's structure, splitting the route tree into named pieces and creating the router inside the component. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src/reactRouterNextProvider.spec.tsx | 920 +++++++++- .../src/reactRouterNextProvider.stories.tsx | 1619 +++++++++++++++-- 2 files changed, 2305 insertions(+), 234 deletions(-) diff --git a/packages/ra-router-react-router-next/src/reactRouterNextProvider.spec.tsx b/packages/ra-router-react-router-next/src/reactRouterNextProvider.spec.tsx index 476086fb705..2a168ea22db 100644 --- a/packages/ra-router-react-router-next/src/reactRouterNextProvider.spec.tsx +++ b/packages/ra-router-react-router-next/src/reactRouterNextProvider.spec.tsx @@ -3,14 +3,25 @@ import { render, screen, waitFor, cleanup } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { Basic, - Embedded, + EmbeddedInReactRouter, + HistoryNavigation, LinkComponent, + MultipleResources, + CustomRoutesSupport, + UseParamsTest, + UseMatchTest, + UseBlockerTest, NavigateComponent, - UseParams, - UseMatch, - UseLocation, - RouterContext, - WarnWhenUnsavedChanges, + UseLocationTest, + RouterContextTest, + NestedRoutesWithOutlet, + NestedResources, + QueryParameters, + NestedResourcesPrecedence, + PathlessLayoutRoutes, + PathlessLayoutRoutesPriority, + PathlessLayoutRoutesWithEmptyRoute, + PathlessLayoutRoutesWithIndexRoute, } from './reactRouterNextProvider.stories'; import { reactRouterNextProvider } from './reactRouterNextProvider'; @@ -145,139 +156,916 @@ describe('reactRouterNextProvider', () => { }); }); - describe('RouterWrapper standalone mode (Basic)', () => { - it('should render the post list inside its own hash router', async () => { + describe('RouterWrapper', () => { + describe('standalone mode (Basic)', () => { + it('should render the post list inside its own hash router', 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 (EmbeddedInReactRouter)', () => { + it('should render the host app 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 mount react-admin under the basename and navigate to it', 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 } + ); + }); + }); + }); + + describe('useNavigate', () => { + it('should navigate to a path programmatically', async () => { + const user = userEvent.setup(); render(); + await waitFor(() => { + expect(screen.getByText('Create')).toBeInTheDocument(); + }); + + await user.click(screen.getByText('Create')); + + await waitFor(() => { + expect(screen.getByText('Create Post')).toBeInTheDocument(); + }); + }); + + it('should navigate back in history with navigate(-1)', async () => { + const user = userEvent.setup(); + render(); + + await screen.findByText('Post #1'); + + await user.click(screen.getByText('Post #1')); + + await waitFor(() => { + expect(screen.getByText('Post Details')).toBeInTheDocument(); + }); + + await user.click(screen.getByText('← Back')); + await waitFor(() => { expect(screen.getByText('Posts')).toBeInTheDocument(); }); }); + }); + + 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 from the list to the show view via a Link', async () => { + it('should navigate when clicked', async () => { const user = userEvent.setup(); render(); await waitFor(() => { expect(screen.getByText('Post #1')).toBeInTheDocument(); }); + await user.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 () => { + const user = userEvent.setup(); + render(); + await waitFor(() => { + expect( + screen.getByText('Go to Post #4 (with search)') + ).toBeInTheDocument(); + }); + + await user.click(screen.getByText('Go to Post #4 (with search)')); + await waitFor(() => { expect(screen.getByText('Post Details')).toBeInTheDocument(); }); + expect( + screen.getByText(/"search": "\?foo=bar"/) + ).toBeInTheDocument(); + }); + + it('should support location object with only search (no pathname)', async () => { + const user = userEvent.setup(); + render(); + await waitFor(() => { + expect( + screen.getByText('Go to same page with search param') + ).toBeInTheDocument(); + }); + + await user.click( + screen.getByText('Go to same page with search param') + ); + + await waitFor(() => { + expect( + screen.getByText('Link Component Tests') + ).toBeInTheDocument(); + }); + expect( + screen.getByText(/"search": "\?foo=bar"/) + ).toBeInTheDocument(); }); }); - describe('RouterWrapper embedded mode with basename (Embedded)', () => { - it('should render the host app home page initially', async () => { - render(); + describe('Routes', () => { + describe('resource routes', () => { + it('should match list routes', async () => { + render(); + await waitFor(() => { + expect(screen.getByText('Posts')).toBeInTheDocument(); + }); + }); + + it('should navigate between resources', async () => { + const user = userEvent.setup(); + render(); + await waitFor(() => { + expect(screen.getByText('Posts')).toBeInTheDocument(); + }); + + await user.click(screen.getByText('Go to Comments')); + + await waitFor(() => { + expect(screen.getByText('Comments')).toBeInTheDocument(); + }); + + await user.click(screen.getByText('Go to Posts')); + + await waitFor(() => { + expect(screen.getByText('Posts')).toBeInTheDocument(); + }); + }); + }); + + describe('custom routes', () => { + it('should render custom routes with layout', async () => { + const user = userEvent.setup(); + render(); + await waitFor(() => { + expect( + screen.getByText('Go to Custom Page') + ).toBeInTheDocument(); + }); + + await user.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 () => { + const user = userEvent.setup(); + render(); + await waitFor(() => { + expect( + screen.getByText('Go to Custom Page (No Layout)') + ).toBeInTheDocument(); + }); + + await user.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 () => { + const user = userEvent.setup(); + render(); + await waitFor(() => { + expect( + screen.getByText('Go to Custom Page') + ).toBeInTheDocument(); + }); + + await user.click(screen.getByText('Go to Custom Page')); + + await waitFor(() => { + expect(screen.getByText('Custom Page')).toBeInTheDocument(); + }); + + await user.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('Home Page')).toBeInTheDocument(); + expect(screen.getByText('Posts')).toBeInTheDocument(); }); + + const paramsDisplay = screen.getByTestId('params-display'); + expect(paramsDisplay.textContent).not.toContain('"id"'); }); - it('should mount react-admin under the basename and navigate to it', async () => { + it('should return id param on show page', async () => { const user = userEvent.setup(); - render(); + render(); await waitFor(() => { - expect(screen.getByText('Admin')).toBeInTheDocument(); + expect(screen.getByText('Post #1')).toBeInTheDocument(); }); - await user.click(screen.getByText('Admin')); + + await user.click(screen.getByText('Post #1')); + await waitFor( () => { - expect(screen.getByText('Posts')).toBeInTheDocument(); + expect( + screen.getByText('Post Details') + ).toBeInTheDocument(); }, { timeout: 3000 } ); + + const paramsDisplay = screen.getByTestId('params-display'); + expect(paramsDisplay.textContent).toContain('"id"'); + expect(paramsDisplay.textContent).toContain('"1"'); }); }); - describe('Link (custom routes, no resource)', () => { - it('should navigate on click', async () => { + 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 () => { const user = userEvent.setup(); - render(); + render(); await waitFor(() => { - expect(screen.getByText('Go to page 2')).toBeInTheDocument(); + expect(screen.getByText('Post #1')).toBeInTheDocument(); }); - await user.click(screen.getByText('Go to page 2')); + + await user.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 () => { + const user = userEvent.setup(); + render(); await waitFor(() => { - expect(screen.getByTestId('page-2')).toBeInTheDocument(); + expect(screen.getByText('Posts List')).toBeInTheDocument(); }); + + await user.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('Navigate (declarative redirect)', () => { - it('should redirect to the target route', async () => { - render(); + describe('useBlocker', () => { + it('should show unblocked state initially', async () => { + render(); await waitFor(() => { - expect(screen.getByTestId('target-page')).toBeInTheDocument(); + expect( + screen.getByText('Form with Unsaved Changes Warning') + ).toBeInTheDocument(); }); + + expect(screen.getByTestId('blocker-state').textContent).toBe( + 'unblocked' + ); + expect(screen.getByTestId('dirty-status').textContent).toBe( + 'No changes' + ); }); - }); - describe('useParams', () => { - it('should expose the URL params', async () => { - render(); + it('should mark form as dirty when input changes', async () => { + const user = userEvent.setup(); + render(); await waitFor(() => { - expect(screen.getByTestId('params')).toHaveTextContent('"42"'); + expect(screen.getByTestId('form-input')).toBeInTheDocument(); + }); + + await user.type(screen.getByTestId('form-input'), 'test'); + + expect(screen.getByTestId('dirty-status').textContent).toBe( + 'Unsaved changes' + ); + }); + + it('should block navigation when form is dirty', async () => { + const user = userEvent.setup(); + render(); + await waitFor(() => { + expect(screen.getByTestId('form-input')).toBeInTheDocument(); + }); + + await user.type(screen.getByTestId('form-input'), 'test'); + + await user.click(screen.getByText('Go to Comments')); + + await waitFor(() => { + expect( + screen.getByTestId('blocker-dialog') + ).toBeInTheDocument(); + }); + + expect(screen.getByTestId('blocker-state').textContent).toBe( + 'blocked' + ); + }); + + it('should allow navigation when clicking proceed', async () => { + const user = userEvent.setup(); + render(); + await waitFor(() => { + expect(screen.getByTestId('form-input')).toBeInTheDocument(); + }); + + await user.type(screen.getByTestId('form-input'), 'test'); + await user.click(screen.getByText('Go to Comments')); + + await waitFor(() => { + expect( + screen.getByTestId('blocker-dialog') + ).toBeInTheDocument(); + }); + + await user.click(screen.getByTestId('blocker-proceed')); + + await waitFor(() => { + expect(screen.getByText('Comments')).toBeInTheDocument(); + }); + }); + + it('should cancel navigation when clicking cancel', async () => { + const user = userEvent.setup(); + render(); + await waitFor(() => { + expect(screen.getByTestId('form-input')).toBeInTheDocument(); + }); + + await user.type(screen.getByTestId('form-input'), 'test'); + await user.click(screen.getByText('Go to Comments')); + + await waitFor(() => { + expect( + screen.getByTestId('blocker-dialog') + ).toBeInTheDocument(); + }); + + await user.click(screen.getByTestId('blocker-cancel')); + + await waitFor(() => { + expect( + screen.queryByTestId('blocker-dialog') + ).not.toBeInTheDocument(); + }); + + expect( + screen.getByText('Form with Unsaved Changes Warning') + ).toBeInTheDocument(); + }); + + it('should not block navigation when form is not dirty', async () => { + const user = userEvent.setup(); + render(); + await waitFor(() => { + expect(screen.getByTestId('form-input')).toBeInTheDocument(); + }); + + await user.click(screen.getByText('Go to Comments')); + + await waitFor(() => { + expect(screen.getByText('Comments')).toBeInTheDocument(); }); }); }); - describe('useMatch', () => { - it('should match the current location against a pattern', async () => { - render(); + describe('Navigate', () => { + it('should redirect to target route', async () => { + const user = userEvent.setup(); + render(); + await waitFor(() => { + expect(screen.getByTestId('posts-page')).toBeInTheDocument(); + }); + + await user.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 () => { + const user = userEvent.setup(); + render(); + await waitFor(() => { + expect(screen.getByTestId('posts-page')).toBeInTheDocument(); + }); + + await user.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 () => { + const user = userEvent.setup(); + render(); await waitFor(() => { - expect(screen.getByTestId('match')).toHaveTextContent('"7"'); + expect(screen.getByTestId('posts-page')).toBeInTheDocument(); + }); + + await user.click(screen.getByText('Go to Conditional Redirect')); + + await waitFor(() => { + expect( + screen.getByText('Conditional Redirect') + ).toBeInTheDocument(); + }); + + await user.click(screen.getByTestId('trigger-redirect')); + + await waitFor(() => { + expect(screen.getByTestId('posts-page')).toBeInTheDocument(); }); }); + + it('should support location object with only search (no pathname)', async () => { + const user = userEvent.setup(); + render(); + await waitFor(() => { + expect(screen.getByTestId('posts-page')).toBeInTheDocument(); + }); + + await user.click( + screen.getByText('Go to Navigate search-only test') + ); + + await waitFor(() => { + expect( + screen.getByTestId('navigate-search-only-page') + ).toBeInTheDocument(); + }); + + expect( + screen.getByText(/"pathname": "\/navigate-search-only"/) + ).toBeInTheDocument(); + expect(screen.getByText(/redirected/)).toBeInTheDocument(); + }); }); describe('useLocation', () => { - it('should expose the current location', async () => { - render(); + 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 () => { + const user = userEvent.setup(); + render(); + await waitFor(() => { + expect(screen.getByText('Go to Post Show')).toBeInTheDocument(); + }); + + await user.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 () => { + const user = userEvent.setup(); + render(); await waitFor(() => { expect( - screen.getByTestId('location-display') + screen.getByText('Go to Post Show (with state)') ).toBeInTheDocument(); }); + + await user.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 / useCanBlock', () => { it('should report being inside a router', async () => { - render(); + render(); await waitFor(() => { expect( - screen.getByTestId('in-router-context') - ).toHaveTextContent('true'); + screen.getByText('Router Context Test') + ).toBeInTheDocument(); }); + + expect( + screen.getByTestId('in-router-context').textContent + ).toContain('true'); + }); + + it('should report that blocking is supported in a data router', async () => { + render(); + await waitFor(() => { + expect( + screen.getByText('Router Context Test') + ).toBeInTheDocument(); + }); + + expect(screen.getByTestId('can-block').textContent).toContain( + 'true' + ); }); }); - describe('useWarnWhenUnsavedChanges', () => { - it('should confirm before navigating away from a dirty form', async () => { - const originalConfirm = window.confirm; - let confirmCalled = false; - // Decline the confirmation so navigation stays blocked. - window.confirm = () => { - confirmCalled = true; - return false; - }; - try { - const user = userEvent.setup(); - render(); - const title = await screen.findByDisplayValue('Post #1'); - await user.type(title, ' edited'); - await user.click(screen.getByText('Go to comments')); - await waitFor(() => { - expect(confirmCalled).toBe(true); - }); - // confirm returned false => navigation blocked, still on the form + describe('Nested Routes with Outlet', () => { + it('should render the default tab content', async () => { + const user = userEvent.setup(); + render(); + await screen.findByText('Post #1'); + + await user.click(screen.getByText('Post #1')); + await screen.findByText('Tabbed Layout (like TabbedShowLayout)'); + + 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 () => { + const user = userEvent.setup(); + render(); + await screen.findByText('Post #1'); + + await user.click(screen.getByText('Post #1')); + await screen.findByTestId('content-tab'); + + await user.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(); + }); + }); + + describe('Nested Resources (Route children of Resource)', () => { + it('should navigate to child routes defined inside Resource', async () => { + const user = userEvent.setup(); + render(); + await screen.findByText('Post #1'); + + await user.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 () => { + const user = userEvent.setup(); + render(); + await screen.findByText('Posts with Query Parameters'); + + expect(screen.getByTestId('current-search').textContent).toContain( + '(empty)' + ); + + await user.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 () => { + const user = userEvent.setup(); + render(); + await screen.findByText('Posts with Query Parameters'); + + await user.click(screen.getByTestId('page-2')); + + await waitFor(() => { expect( - screen.getByDisplayValue('Post #1 edited') + 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 () => { + const user = userEvent.setup(); + render(); + await screen.findByText('Posts with Query Parameters'); + + await user.click(screen.getByTestId('sort-title')); + + await waitFor(() => { + expect( + screen.getByTestId('current-search').textContent + ).toContain('sort=title'); + }); + + await user.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'; + const user = userEvent.setup(); + + render(); + + await waitFor(() => { + expect(screen.getByText('Layout Wrapper')).toBeInTheDocument(); + expect(screen.getByTestId('posts-page')).toBeInTheDocument(); + }); + + await user.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'; + const user = userEvent.setup(); + + render(); + + await waitFor(() => { + expect(screen.getByTestId('posts-page')).toBeInTheDocument(); + }); + + await user.click(screen.getByText('User')); + + await waitFor(() => { + expect(screen.getByTestId('users-page')).toBeInTheDocument(); + }); + + await user.click(screen.getByText('Block a user')); + + await waitFor(() => { + expect( + screen.getByTestId('block-user-page') ).toBeInTheDocument(); - } finally { - window.confirm = originalConfirm; - } + }); + }); + + it('should match the empty path route as most specific within pathless layout routes', async () => { + window.location.hash = '#/posts'; + const user = userEvent.setup(); + + render(); + + await waitFor(() => { + expect(screen.getByTestId('posts-page')).toBeInTheDocument(); + }); + + await user.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'; + const user = userEvent.setup(); + + render(); + + await waitFor(() => { + expect(screen.getByTestId('posts-page')).toBeInTheDocument(); + }); + + await user.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 () => { + const user = userEvent.setup(); + render(); + + await screen.findByText('Post #1'); + + await user.click(screen.getByText('Post #1')); + + await screen.findByText('Post Details'); + + await user.click(screen.getByText('View Comments')); + + 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 index e27e12499cf..413638003cf 100644 --- a/packages/ra-router-react-router-next/src/reactRouterNextProvider.stories.tsx +++ b/packages/ra-router-react-router-next/src/reactRouterNextProvider.stories.tsx @@ -8,16 +8,19 @@ import { useNavigate as useReactRouterNavigate, } from 'react-router'; import { - CoreAdmin, - Resource, - CustomRoutes, + useNavigate, + useLocation, + LinkBase, + useBlocker, ListBase, ShowBase, EditBase, CreateBase, useRecordContext, - useLocation, - LinkBase, + CoreAdmin, + Resource, + CustomRoutes, + RouterProviderContext, testUI, } from 'ra-core'; import { reactRouterNextProvider } from './reactRouterNextProvider'; @@ -62,7 +65,6 @@ const PostList = () => (

    Posts

    - ( @@ -70,6 +72,7 @@ const PostList = () => ( )} /> +
    ); @@ -129,7 +132,11 @@ const LocationDisplay = () => { ); }; -const Layout = ({ children }: { children?: React.ReactNode }) => ( +const LayoutWithLocationDisplay = ({ + children, +}: { + children?: React.ReactNode; +}) => (
    {children} @@ -137,14 +144,14 @@ const Layout = ({ children }: { children?: React.ReactNode }) => ( ); /** - * The most basic setup: react-admin runs on its own hash router created by the - * provider, with a single resource exercising list/show/edit/create. + * Basic: Admin creates its own React Router v8 (standalone mode) + * Tests basic navigation, links, and programmatic navigation. */ export const Basic = () => ( ( ); /** - * react-admin embedded inside an existing React Router app, mounted under a - * basename. Exercises the provider detecting the surrounding router and the - * basename support. + * 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 ( @@ -168,6 +175,7 @@ const EmbeddedNav = () => { Home + {/* Link to /admin/posts to trigger react-admin's routing */} { ); }; +// 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 = () => ( - + ); -const embeddedRouter = createHashRouter([ +// Splat route to handle /admin, /admin/posts, /admin/posts/1/show, etc. +const embeddedAdminRoute = { path: 'admin/*', element: }; + +const embeddedRouteTree = [ { path: '/', - element: ( -
    - - -
    - ), - children: [ - { - index: true, - element: ( -
    -

    Home Page

    -

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

    - - Go to Admin - -
    - ), - }, - { path: 'admin/*', element: }, - ], + ...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), []); -export const Embedded = () => ; + return ; +}; /** - * Tests the provider Link (through LinkBase) using custom routes and no resource. + * Tests back/forward navigation */ -const LinkPage = () => ( -
    -

    Link Component

    - Go to page 2 - - Go to page 2 with search - - - Go to page 2 with state - -
    -); +export const HistoryNavigation = () => { + const HistoryButtons = () => { + const navigate = useNavigate(); + return ( +
    + + +
    + ); + }; -const Page2 = () => ( -
    -

    Page 2

    - Back - -
    -); + const ListWithHistory = () => ( +
    + + +
    + ); -export const LinkComponent = () => ( - - - } /> - } /> - - -); + const ShowWithHistory = () => ( +
    + + +
    + ); + + return ( + + } + show={} + /> + + ); +}; /** - * Tests the provider Navigate (declarative redirect) using custom routes. + * Tests that routes match correctly + * Tests resource routes, custom routes, and catch-all routes. */ -const RedirectPage = () => ; +export const RouteMatching = () => { + const Dashboard = () => ( +
    +

    Dashboard

    +

    Welcome to the admin dashboard.

    +
      +
    • + Posts +
    • +
    +
    + ); -const TargetPage = () => ( -
    - Target page -
    -); + return ( + + + + ); +}; -export const NavigateComponent = () => ( - - - } /> - } /> - - -); +/** + * 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 the useParams hook using a custom route. + * Tests navigation between multiple resources */ -const ParamsReader = () => { - const params = useParams(); - return
    {JSON.stringify(params)}
    ; +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 UseParams = () => ( - - - } /> - } /> - - -); +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) + +
    + + } + /> +
    + ); +}; /** - * Tests the useMatch hook using a custom route. + * Displays URL parameters extracted from the current route. */ -const MatchReader = () => { - const match = useMatch({ path: '/posts/:id/show' }); +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 ( -
    {JSON.stringify(match?.params ?? null)}
    + + +

    Posts

    + +
      +
    • + Post #1 +
    • +
    • + Post #2 +
    • +
    + + } + show={} + /> +
    ); }; -export const UseMatch = () => ( - - - } /> - } /> - - -); +/** + * 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

    +
    + + } + /> +
    + ); +}; /** - * Tests the useLocation hook using a custom route. + * Blocks navigation when there are unsaved changes. */ -export const UseLocation = () => ( - - - } /> - - -); +export const UseBlockerTest = () => { + const FormWithBlocker = () => { + const [isDirty, setIsDirty] = React.useState(false); + const [inputValue, setInputValue] = React.useState(''); + + const blocker = useBlocker( + ({ currentLocation, nextLocation }) => + isDirty && currentLocation.pathname !== nextLocation.pathname + ); + + return ( +
    +

    Form with Unsaved Changes Warning

    +
    + +
    +
    + + {isDirty ? 'Unsaved changes' : 'No changes'} + +
    +
    + +
    +
    + Go to Comments +
    + {blocker.state === 'blocked' && ( +
    +
    +

    Unsaved Changes

    +

    + You have unsaved changes. Are you sure you want + to leave? +

    + + +
    +
    + )} +
    + Blocker State:{' '} + {blocker.state} +
    +
    + ); + }; + + 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 +
    + } + /> +
    + ); +}; /** - * Tests the useInRouterContext and useCanBlock hooks using a custom route. + * RouterContextTest: Tests useInRouterContext and useCanBlock hooks */ -const ContextInfo = () => { - const isInRouter = useInRouterContext(); - const canBlock = useCanBlock(); +export const RouterContextTest = () => { + const ContextInfo = () => { + const isInRouter = useInRouterContext(); + const canBlock = useCanBlock(); + + return ( +
    +

    Router Context Info:

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

    Router Context Test

    + + + } + /> +
    ); }; -export const RouterContext = () => ( +const { Routes, Outlet: RouterOutlet } = reactRouterNextProvider; + +export const NestedResources = () => ( - - } /> - + }> + } /> + ); -/** - * Tests navigation blocking through react-admin's built-in - * useWarnWhenUnsavedChanges (via the Form `warnWhenUnsavedChanges` prop), rather - * than a hand-rolled blocker. When the form is dirty, leaving triggers a - * `window.confirm` before navigating away. - */ -const PostEditWithWarning = () => ( - -
    -

    Edit Post

    - - - - - Go to comments -
    -
    -); +const PostEditWithLinkToComments = () => { + const navigate = useNavigate(); + return ( + ( +
    +

    Post Details

    + {record &&

    {record.title}

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

    Comments for Post {post_id}

    +
      + {data?.map(record => ( +
    • {record.body}
    • + ))} +
    + +
    + )} + /> + ); +}; + +export const NestedResourcesPrecedence = () => ( - -

    Comments

    } /> - - } /> - + + } /> +
    ); + +/** + * 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 + + } + /> + +
    + +
    +
    + ); +}; From fbd10e848273e6eebc25a2676b70000fb147f268 Mon Sep 17 00:00:00 2001 From: Shaoyu Meng Date: Wed, 24 Jun 2026 23:53:03 -0700 Subject: [PATCH 23/56] test: Use built-in unsaved-changes guard in React Router v8 adapter stories Replace the hand-rolled useBlocker form story with one that relies on react-admin's useWarnWhenUnsavedChanges (via SimpleForm's warnWhenUnsavedChanges prop), inline PostShow fields, and drop the unused jest project displayName. Co-Authored-By: Claude Opus 4.8 (1M context) --- jest.config.js | 5 +- .../jest.config.cjs | 1 - .../src/reactRouterNextProvider.spec.tsx | 176 +++++++----------- .../src/reactRouterNextProvider.stories.tsx | 153 +++------------ 4 files changed, 97 insertions(+), 238 deletions(-) diff --git a/jest.config.js b/jest.config.js index ca2732b8a72..a755fa473fa 100644 --- a/jest.config.js +++ b/jest.config.js @@ -42,9 +42,8 @@ module.exports = { }, moduleNameMapper, }, - // ra-router-react-router-next supplies its own (ESM, React 19) project - // config. Running its tests requires `NODE_OPTIONS=--experimental-vm-modules` - // (set in the test scripts). + // ra-router-react-router-next can't be compiled into cjs. + // Running its tests requires `NODE_OPTIONS=--experimental-vm-modules`. './packages/ra-router-react-router-next/jest.config.cjs', ], testTimeout: 60000, diff --git a/packages/ra-router-react-router-next/jest.config.cjs b/packages/ra-router-react-router-next/jest.config.cjs index 54e036669c7..58a59aa6b72 100644 --- a/packages/ra-router-react-router-next/jest.config.cjs +++ b/packages/ra-router-react-router-next/jest.config.cjs @@ -33,7 +33,6 @@ const reactDom19Dir = path.dirname( ); module.exports = { - displayName: 'react-router-v8', rootDir: repoRoot, roots: [__dirname], globalSetup: '/test-global-setup.js', diff --git a/packages/ra-router-react-router-next/src/reactRouterNextProvider.spec.tsx b/packages/ra-router-react-router-next/src/reactRouterNextProvider.spec.tsx index 2a168ea22db..cc7e5d0066f 100644 --- a/packages/ra-router-react-router-next/src/reactRouterNextProvider.spec.tsx +++ b/packages/ra-router-react-router-next/src/reactRouterNextProvider.spec.tsx @@ -10,7 +10,7 @@ import { CustomRoutesSupport, UseParamsTest, UseMatchTest, - UseBlockerTest, + UseWarnWhenUnsavedChangesTest, NavigateComponent, UseLocationTest, RouterContextTest, @@ -539,123 +539,77 @@ describe('reactRouterNextProvider', () => { }); }); - describe('useBlocker', () => { - it('should show unblocked state initially', async () => { - render(); - await waitFor(() => { + describe('useWarnWhenUnsavedChanges', () => { + it('should confirm before navigating away from a dirty form', async () => { + const originalConfirm = window.confirm; + let confirmCalled = false; + // Decline the confirmation so navigation stays blocked. + 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'); + await user.click(screen.getByText('Go to Comments')); + await waitFor(() => { + expect(confirmCalled).toBe(true); + }); + // confirm returned false => navigation blocked, still on the form expect( screen.getByText('Form with Unsaved Changes Warning') ).toBeInTheDocument(); - }); - - expect(screen.getByTestId('blocker-state').textContent).toBe( - 'unblocked' - ); - expect(screen.getByTestId('dirty-status').textContent).toBe( - 'No changes' - ); - }); - - it('should mark form as dirty when input changes', async () => { - const user = userEvent.setup(); - render(); - await waitFor(() => { - expect(screen.getByTestId('form-input')).toBeInTheDocument(); - }); - - await user.type(screen.getByTestId('form-input'), 'test'); - - expect(screen.getByTestId('dirty-status').textContent).toBe( - 'Unsaved changes' - ); - }); - - it('should block navigation when form is dirty', async () => { - const user = userEvent.setup(); - render(); - await waitFor(() => { - expect(screen.getByTestId('form-input')).toBeInTheDocument(); - }); - - await user.type(screen.getByTestId('form-input'), 'test'); - - await user.click(screen.getByText('Go to Comments')); - - await waitFor(() => { - expect( - screen.getByTestId('blocker-dialog') - ).toBeInTheDocument(); - }); - - expect(screen.getByTestId('blocker-state').textContent).toBe( - 'blocked' - ); - }); - - it('should allow navigation when clicking proceed', async () => { - const user = userEvent.setup(); - render(); - await waitFor(() => { - expect(screen.getByTestId('form-input')).toBeInTheDocument(); - }); - - await user.type(screen.getByTestId('form-input'), 'test'); - await user.click(screen.getByText('Go to Comments')); - - await waitFor(() => { expect( - screen.getByTestId('blocker-dialog') + screen.getByDisplayValue('A new title') ).toBeInTheDocument(); - }); - - await user.click(screen.getByTestId('blocker-proceed')); - - await waitFor(() => { - expect(screen.getByText('Comments')).toBeInTheDocument(); - }); + } finally { + window.confirm = originalConfirm; + } }); - it('should cancel navigation when clicking cancel', async () => { - const user = userEvent.setup(); - render(); - await waitFor(() => { - expect(screen.getByTestId('form-input')).toBeInTheDocument(); - }); - - await user.type(screen.getByTestId('form-input'), 'test'); - await user.click(screen.getByText('Go to Comments')); - - await waitFor(() => { - expect( - screen.getByTestId('blocker-dialog') - ).toBeInTheDocument(); - }); - - await user.click(screen.getByTestId('blocker-cancel')); - - await waitFor(() => { - expect( - screen.queryByTestId('blocker-dialog') - ).not.toBeInTheDocument(); - }); - - expect( - screen.getByText('Form with Unsaved Changes Warning') - ).toBeInTheDocument(); + it('should navigate away from a dirty form once confirmed', async () => { + const originalConfirm = window.confirm; + // Accept the confirmation so navigation proceeds. + window.confirm = () => true; + try { + const user = userEvent.setup(); + render(); + const [title] = await screen.findAllByRole('textbox'); + await user.type(title, 'A new title'); + await user.click(screen.getByText('Go to Comments')); + await waitFor(() => { + expect( + screen.getByText('You navigated away from the form.') + ).toBeInTheDocument(); + }); + } finally { + window.confirm = originalConfirm; + } }); - it('should not block navigation when form is not dirty', async () => { - const user = userEvent.setup(); - render(); - await waitFor(() => { - expect(screen.getByTestId('form-input')).toBeInTheDocument(); - }); - - await user.click(screen.getByText('Go to Comments')); - - await waitFor(() => { - expect(screen.getByText('Comments')).toBeInTheDocument(); - }); + it('should not confirm when the form is not dirty', async () => { + const originalConfirm = window.confirm; + let confirmCalled = false; + window.confirm = () => { + confirmCalled = true; + return true; + }; + try { + const user = userEvent.setup(); + render(); + await screen.findByText('Form with Unsaved Changes Warning'); + await user.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; + } }); }); @@ -741,7 +695,9 @@ describe('reactRouterNextProvider', () => { expect( screen.getByText(/"pathname": "\/navigate-search-only"/) ).toBeInTheDocument(); - expect(screen.getByText(/redirected/)).toBeInTheDocument(); + expect( + screen.getByText(/"search": "\?redirected=true"/) + ).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 index 413638003cf..8d072c61625 100644 --- a/packages/ra-router-react-router-next/src/reactRouterNextProvider.stories.tsx +++ b/packages/ra-router-react-router-next/src/reactRouterNextProvider.stories.tsx @@ -11,7 +11,6 @@ import { useNavigate, useLocation, LinkBase, - useBlocker, ListBase, ShowBase, EditBase, @@ -37,7 +36,7 @@ const { TextInput, SimpleList, SimpleShowLayout, SimpleForm, CreateButton } = testUI; export default { - title: 'ra-routing-react-router-next/React Router Provider', + title: 'ra-routing-react-router-next/React Router v8 Provider', }; const dataProvider = fakeDataProvider( @@ -56,11 +55,6 @@ const dataProvider = fakeDataProvider( process.env.NODE_ENV === 'development' ); -const Field = ({ source }: { source: string }) => { - const record = useRecordContext(); - return {record?.[source]}; -}; - const PostList = () => (
    @@ -78,16 +72,20 @@ const PostList = () => ( ); const PostShow = () => ( - -
    -

    Post Details

    - - - - - Back to list -
    -
    + ( +
    +

    Post Details

    + + ID: {record?.id} + Title: {record?.title} + Body: {record?.body} + + Back to list +
    + )} + /> ); const PostEdit = () => ( @@ -118,7 +116,6 @@ const LocationDisplay = () => { const location = useLocation(); return (
    { > Current Location:
    {JSON.stringify(location, null, 2)}
    +
    window.location.hash: {window.location.hash}
    ); }; @@ -135,7 +133,7 @@ const LocationDisplay = () => { const LayoutWithLocationDisplay = ({ children, }: { - children?: React.ReactNode; + children: React.ReactNode; }) => (
    {children} @@ -652,117 +650,24 @@ export const UseMatchTest = () => { /** * Blocks navigation when there are unsaved changes. */ -export const UseBlockerTest = () => { - const FormWithBlocker = () => { - const [isDirty, setIsDirty] = React.useState(false); - const [inputValue, setInputValue] = React.useState(''); - - const blocker = useBlocker( - ({ currentLocation, nextLocation }) => - isDirty && currentLocation.pathname !== nextLocation.pathname - ); - - return ( -
    -

    Form with Unsaved Changes Warning

    -
    - -
    -
    - - {isDirty ? 'Unsaved changes' : 'No changes'} - -
    -
    - -
    -
    - Go to Comments -
    - {blocker.state === 'blocked' && ( -
    -
    -

    Unsaved Changes

    -

    - You have unsaved changes. Are you sure you want - to leave? -

    - - -
    -
    - )} -
    - Blocker State:{' '} - {blocker.state} -
    -
    - ); - }; +export const UseWarnWhenUnsavedChangesTest = () => { + const FormWithWarnWhenUnsavedChanges = () => ( +
    +

    Form with Unsaved Changes Warning

    + + + + + Go to Comments +
    + ); return ( - } /> + } /> Date: Thu, 25 Jun 2026 00:06:34 -0700 Subject: [PATCH 24/56] test: Mirror routing stories and tests into ra-router-react-router Copy the full ra-router-react-router-next story suite and its spec into the react-router v6/v7 adapter so both providers share identical coverage. Only the Storybook title, the provider identifier, and the host-router imports (react-router / react-router-dom split) differ. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src/reactRouterProvider.spec.tsx | 1027 ++++++++++ .../src/reactRouterProvider.stories.tsx | 1681 ++++++++++++++--- 2 files changed, 2489 insertions(+), 219 deletions(-) create mode 100644 packages/ra-router-react-router/src/reactRouterProvider.spec.tsx 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..64f3335b5a5 --- /dev/null +++ b/packages/ra-router-react-router/src/reactRouterProvider.spec.tsx @@ -0,0 +1,1027 @@ +import * as React from 'react'; +import { render, screen, 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, + NestedResourcesPrecedence, + PathlessLayoutRoutes, + PathlessLayoutRoutesPriority, + PathlessLayoutRoutesWithEmptyRoute, + PathlessLayoutRoutesWithIndexRoute, +} from './reactRouterProvider.stories'; +import { reactRouterProvider } from './reactRouterProvider'; + +const { matchPath } = reactRouterProvider; + +describe('reactRouterProvider', () => { + 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 a nested path', () => { + expect(matchPath('/*', '/posts/1')).toMatchObject({ + params: { '*': 'posts/1' }, + pathname: '/posts/1', + pathnameBase: '/', + }); + }); + }); + + describe('root/empty paths', () => { + it('should match "/" against "/"', () => { + expect(matchPath('/', '/')).toMatchObject({ + params: {}, + pathname: '/', + pathnameBase: '/', + }); + }); + + it('should not match "/" against "/posts" by default (end=true)', () => { + expect(matchPath('/', '/posts')).toBeNull(); + }); + }); + + describe('static paths', () => { + it('should match an exact static path', () => { + expect(matchPath('/posts', '/posts')).toMatchObject({ + params: {}, + pathname: '/posts', + pathnameBase: '/posts', + }); + }); + + it('should not match a static path against a longer path', () => { + expect(matchPath('/posts', '/posts/1')).toBeNull(); + }); + + it('should match a static path as a prefix with end=false', () => { + expect( + matchPath({ path: '/posts', end: false }, '/posts/1') + ).toMatchObject({ + params: {}, + pathname: '/posts', + }); + }); + }); + + describe('dynamic params', () => { + it('should match a single param', () => { + expect(matchPath('/posts/:id', '/posts/1')).toMatchObject({ + params: { id: '1' }, + pathname: '/posts/1', + }); + }); + + it('should match multiple params', () => { + expect( + matchPath('/:resource/:id/show', '/posts/1/show') + ).toMatchObject({ + params: { resource: 'posts', id: '1' }, + pathname: '/posts/1/show', + }); + }); + + it('should not match a param when the segment is missing', () => { + expect(matchPath('/posts/:id', '/posts')).toBeNull(); + }); + }); + + describe('react-admin resource patterns', () => { + it('should match a resource list', () => { + expect(matchPath('/:resource', '/posts')).toMatchObject({ + params: { resource: 'posts' }, + }); + }); + + it('should match a resource edit', () => { + expect(matchPath('/:resource/:id', '/posts/1')).toMatchObject({ + params: { resource: 'posts', id: '1' }, + }); + }); + }); + + describe('basename scenarios (pathname already stripped of basename)', () => { + it('should match a path after the basename is stripped', () => { + // basename "/admin" + "/admin/posts" => matchPath sees "/posts" + expect(matchPath('/posts', '/posts')).toMatchObject({ + params: {}, + pathname: '/posts', + }); + }); + + it('should match a catch-all after the basename is stripped', () => { + // "/admin/posts/1" with basename "/admin" => "/posts/1" + expect(matchPath('/*', '/posts/1')).toMatchObject({ + params: { '*': 'posts/1' }, + }); + }); + + it('should match a nested resource after the basename is stripped', () => { + // "/admin/posts/1/show" with basename "/admin" => "/posts/1/show" + expect( + matchPath('/:resource/:id/show', '/posts/1/show') + ).toMatchObject({ + params: { resource: 'posts', id: '1' }, + }); + }); + }); + }); + + describe('RouterWrapper', () => { + describe('standalone mode (Basic)', () => { + it('should render the post list inside its own hash router', 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 (EmbeddedInReactRouter)', () => { + it('should render the host app 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 mount react-admin under the basename and navigate to it', 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 } + ); + }); + }); + }); + + describe('useNavigate', () => { + it('should navigate to a path programmatically', async () => { + const user = userEvent.setup(); + render(); + await waitFor(() => { + expect(screen.getByText('Create')).toBeInTheDocument(); + }); + + await user.click(screen.getByText('Create')); + + await waitFor(() => { + expect(screen.getByText('Create Post')).toBeInTheDocument(); + }); + }); + + it('should navigate back in history with navigate(-1)', async () => { + const user = userEvent.setup(); + render(); + + await screen.findByText('Post #1'); + + await user.click(screen.getByText('Post #1')); + + await waitFor(() => { + expect(screen.getByText('Post Details')).toBeInTheDocument(); + }); + + await user.click(screen.getByText('← Back')); + + await waitFor(() => { + expect(screen.getByText('Posts')).toBeInTheDocument(); + }); + }); + }); + + 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 () => { + const user = userEvent.setup(); + render(); + await waitFor(() => { + expect(screen.getByText('Post #1')).toBeInTheDocument(); + }); + + await user.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 () => { + const user = userEvent.setup(); + render(); + await waitFor(() => { + expect( + screen.getByText('Go to Post #4 (with search)') + ).toBeInTheDocument(); + }); + + await user.click(screen.getByText('Go to Post #4 (with search)')); + + await waitFor(() => { + expect(screen.getByText('Post Details')).toBeInTheDocument(); + }); + expect( + screen.getByText(/"search": "\?foo=bar"/) + ).toBeInTheDocument(); + }); + + it('should support location object with only search (no pathname)', async () => { + const user = userEvent.setup(); + render(); + await waitFor(() => { + expect( + screen.getByText('Go to same page with search param') + ).toBeInTheDocument(); + }); + + await user.click( + screen.getByText('Go to same page with search param') + ); + + await waitFor(() => { + expect( + screen.getByText('Link Component Tests') + ).toBeInTheDocument(); + }); + 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 navigate between resources', async () => { + const user = userEvent.setup(); + render(); + await waitFor(() => { + expect(screen.getByText('Posts')).toBeInTheDocument(); + }); + + await user.click(screen.getByText('Go to Comments')); + + await waitFor(() => { + expect(screen.getByText('Comments')).toBeInTheDocument(); + }); + + await user.click(screen.getByText('Go to Posts')); + + await waitFor(() => { + expect(screen.getByText('Posts')).toBeInTheDocument(); + }); + }); + }); + + describe('custom routes', () => { + it('should render custom routes with layout', async () => { + const user = userEvent.setup(); + render(); + await waitFor(() => { + expect( + screen.getByText('Go to Custom Page') + ).toBeInTheDocument(); + }); + + await user.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 () => { + const user = userEvent.setup(); + render(); + await waitFor(() => { + expect( + screen.getByText('Go to Custom Page (No Layout)') + ).toBeInTheDocument(); + }); + + await user.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 () => { + const user = userEvent.setup(); + render(); + await waitFor(() => { + expect( + screen.getByText('Go to Custom Page') + ).toBeInTheDocument(); + }); + + await user.click(screen.getByText('Go to Custom Page')); + + await waitFor(() => { + expect(screen.getByText('Custom Page')).toBeInTheDocument(); + }); + + await user.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 () => { + const user = userEvent.setup(); + render(); + await waitFor(() => { + expect(screen.getByText('Post #1')).toBeInTheDocument(); + }); + + await user.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"'); + }); + }); + + 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 () => { + const user = userEvent.setup(); + render(); + await waitFor(() => { + expect(screen.getByText('Post #1')).toBeInTheDocument(); + }); + + await user.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 () => { + const user = userEvent.setup(); + render(); + await waitFor(() => { + expect(screen.getByText('Posts List')).toBeInTheDocument(); + }); + + await user.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 confirm before navigating away from a dirty form', async () => { + const originalConfirm = window.confirm; + let confirmCalled = false; + // Decline the confirmation so navigation stays blocked. + 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'); + await user.click(screen.getByText('Go to Comments')); + await waitFor(() => { + expect(confirmCalled).toBe(true); + }); + // confirm returned false => navigation blocked, still on the form + expect( + screen.getByText('Form with Unsaved Changes Warning') + ).toBeInTheDocument(); + expect( + screen.getByDisplayValue('A new title') + ).toBeInTheDocument(); + } finally { + window.confirm = originalConfirm; + } + }); + + it('should navigate away from a dirty form once confirmed', async () => { + const originalConfirm = window.confirm; + // Accept the confirmation so navigation proceeds. + window.confirm = () => true; + try { + const user = userEvent.setup(); + render(); + const [title] = await screen.findAllByRole('textbox'); + await user.type(title, 'A new title'); + await user.click(screen.getByText('Go to Comments')); + await waitFor(() => { + expect( + screen.getByText('You navigated away from the form.') + ).toBeInTheDocument(); + }); + } finally { + window.confirm = originalConfirm; + } + }); + + it('should not confirm when the form is not dirty', async () => { + const originalConfirm = window.confirm; + let confirmCalled = false; + window.confirm = () => { + confirmCalled = true; + return true; + }; + try { + const user = userEvent.setup(); + render(); + await screen.findByText('Form with Unsaved Changes Warning'); + await user.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 () => { + const user = userEvent.setup(); + render(); + await waitFor(() => { + expect(screen.getByTestId('posts-page')).toBeInTheDocument(); + }); + + await user.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 () => { + const user = userEvent.setup(); + render(); + await waitFor(() => { + expect(screen.getByTestId('posts-page')).toBeInTheDocument(); + }); + + await user.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 () => { + const user = userEvent.setup(); + render(); + await waitFor(() => { + expect(screen.getByTestId('posts-page')).toBeInTheDocument(); + }); + + await user.click(screen.getByText('Go to Conditional Redirect')); + + await waitFor(() => { + expect( + screen.getByText('Conditional Redirect') + ).toBeInTheDocument(); + }); + + await user.click(screen.getByTestId('trigger-redirect')); + + await waitFor(() => { + expect(screen.getByTestId('posts-page')).toBeInTheDocument(); + }); + }); + + it('should support location object with only search (no pathname)', async () => { + const user = userEvent.setup(); + render(); + await waitFor(() => { + expect(screen.getByTestId('posts-page')).toBeInTheDocument(); + }); + + await user.click( + screen.getByText('Go to Navigate search-only test') + ); + + await waitFor(() => { + expect( + screen.getByTestId('navigate-search-only-page') + ).toBeInTheDocument(); + }); + + expect( + screen.getByText(/"pathname": "\/navigate-search-only"/) + ).toBeInTheDocument(); + 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 () => { + const user = userEvent.setup(); + render(); + await waitFor(() => { + expect(screen.getByText('Go to Post Show')).toBeInTheDocument(); + }); + + await user.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 () => { + const user = userEvent.setup(); + render(); + await waitFor(() => { + expect( + screen.getByText('Go to Post Show (with state)') + ).toBeInTheDocument(); + }); + + await user.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 / useCanBlock', () => { + it('should report being inside a router', async () => { + render(); + await waitFor(() => { + expect( + screen.getByText('Router Context Test') + ).toBeInTheDocument(); + }); + + expect( + screen.getByTestId('in-router-context').textContent + ).toContain('true'); + }); + + it('should report that blocking is supported in a data 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 () => { + const user = userEvent.setup(); + render(); + await screen.findByText('Post #1'); + + await user.click(screen.getByText('Post #1')); + await screen.findByText('Tabbed Layout (like TabbedShowLayout)'); + + 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 () => { + const user = userEvent.setup(); + render(); + await screen.findByText('Post #1'); + + await user.click(screen.getByText('Post #1')); + await screen.findByTestId('content-tab'); + + await user.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(); + }); + }); + + describe('Nested Resources (Route children of Resource)', () => { + it('should navigate to child routes defined inside Resource', async () => { + const user = userEvent.setup(); + render(); + await screen.findByText('Post #1'); + + await user.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 () => { + const user = userEvent.setup(); + render(); + await screen.findByText('Posts with Query Parameters'); + + expect(screen.getByTestId('current-search').textContent).toContain( + '(empty)' + ); + + await user.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 () => { + const user = userEvent.setup(); + render(); + await screen.findByText('Posts with Query Parameters'); + + await user.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 () => { + const user = userEvent.setup(); + render(); + await screen.findByText('Posts with Query Parameters'); + + await user.click(screen.getByTestId('sort-title')); + + await waitFor(() => { + expect( + screen.getByTestId('current-search').textContent + ).toContain('sort=title'); + }); + + await user.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'; + const user = userEvent.setup(); + + render(); + + await waitFor(() => { + expect(screen.getByText('Layout Wrapper')).toBeInTheDocument(); + expect(screen.getByTestId('posts-page')).toBeInTheDocument(); + }); + + await user.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'; + const user = userEvent.setup(); + + render(); + + await waitFor(() => { + expect(screen.getByTestId('posts-page')).toBeInTheDocument(); + }); + + await user.click(screen.getByText('User')); + + await waitFor(() => { + expect(screen.getByTestId('users-page')).toBeInTheDocument(); + }); + + await user.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'; + const user = userEvent.setup(); + + render(); + + await waitFor(() => { + expect(screen.getByTestId('posts-page')).toBeInTheDocument(); + }); + + await user.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'; + const user = userEvent.setup(); + + render(); + + await waitFor(() => { + expect(screen.getByTestId('posts-page')).toBeInTheDocument(); + }); + + await user.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 () => { + const user = userEvent.setup(); + render(); + + await screen.findByText('Post #1'); + + await user.click(screen.getByText('Post #1')); + + await screen.findByText('Post Details'); + + await user.click(screen.getByText('View Comments')); + + 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 index d9522ba5c7c..32d5b8977e2 100644 --- a/packages/ra-router-react-router/src/reactRouterProvider.stories.tsx +++ b/packages/ra-router-react-router/src/reactRouterProvider.stories.tsx @@ -1,26 +1,38 @@ import * as React from 'react'; import fakeDataProvider from 'ra-data-fakerest'; import { - CoreAdmin, - Resource, - CustomRoutes, + 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, - useNavigate, - useLocation, - LinkBase, - useBlocker, - Form, + CoreAdmin, + Resource, + CustomRoutes, + RouterProviderContext, testUI, } from 'ra-core'; import { reactRouterProvider } from './reactRouterProvider'; -const { useParams, useMatch, useInRouterContext, Route, Navigate } = - reactRouterProvider; -const { TextInput } = testUI; +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', @@ -42,97 +54,101 @@ const dataProvider = fakeDataProvider( process.env.NODE_ENV === 'development' ); -const LocationDisplay = () => { - const location = useLocation(); - return
    {location.pathname}
    ; -}; - -const Layout = ({ children }: { children?: React.ReactNode }) => ( -
    - - {children} -
    +const PostList = () => ( + +
    +

    Posts

    + ( + + {record.title} + + )} + /> + +
    +
    ); -const PostList = () => { - const navigate = useNavigate(); - return ( - ( -
    -

    Posts

    -
      - {data?.map(record => ( -
    • - - {record.title} - -
    • - ))} -
    - -
    - )} - /> - ); -}; - -const PostShow = () => { - const navigate = useNavigate(); - const { id } = useParams<{ id: string }>(); - return ( - - navigate(`/posts/${id}`)} /> - - ); -}; +const PostShow = () => ( + ( +
    +

    Post Details

    + + ID: {record?.id} + Title: {record?.title} + Body: {record?.body} + + Back to list +
    + )} + /> +); -const PostShowView = ({ onEdit }: { onEdit: () => void }) => { - const record = useRecordContext(); - if (!record) return null; - return ( +const PostEdit = () => ( +
    -

    {record.title}

    -

    {record.body}

    - - Back to list +

    Edit Post

    + + + +
    - ); -}; +
    +); -const PostEdit = () => { - const { id } = useParams<{ id: string }>(); - return ( - -
    +const PostCreate = () => ( + +
    +

    Create Post

    + - - + +
    +
    +); + +const LocationDisplay = () => { + const location = useLocation(); + return ( +
    + Current Location: +
    {JSON.stringify(location, null, 2)}
    +
    window.location.hash: {window.location.hash}
    +
    ); }; -const PostCreate = () => ( - -
    - - - -
    +const LayoutWithLocationDisplay = ({ + children, +}: { + children: React.ReactNode; +}) => ( +
    + {children} + +
    ); /** - * BasicStandalone: react-admin runs on its own hash router created by the - * react-router provider (no surrounding router). + * Basic: Admin creates its own React Router (standalone mode) + * Tests basic navigation, links, and programmatic navigation. */ -export const BasicStandalone = () => ( +export const Basic = () => ( ( ); /** - * MultipleResources: several resources sharing the provider. + * EmbeddedInReactRouter: Admin inside an existing React Router app + * Tests that react-admin detects existing router and uses it. */ -const CommentList = () => ( - ( -
      - {data?.map(record =>
    • {record.body}
    • )} -
    - )} - /> -); +// 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 +
    + ), +}; -export const MultipleResources = () => ( +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], + }, +]; + /** - * LinkComponent: navigation through LinkBase (which renders the provider Link). + * Admin inside an existing React Router app + * Tests that react-admin detects existing router and uses it. */ -export const LinkComponent = () => ( - - - -); +export const EmbeddedInReactRouter = () => { + const router = React.useMemo(() => createHashRouter(embeddedRouteTree), []); + + return ; +}; /** - * NavigateComponent: declarative redirect through the provider Navigate. + * Tests back/forward navigation */ -const RedirectToPosts = () => ; +export const HistoryNavigation = () => { + const HistoryButtons = () => { + const navigate = useNavigate(); + return ( +
    + + +
    + ); + }; -export const NavigateComponent = () => ( - - - } /> - - - -); + const ListWithHistory = () => ( +
    + + +
    + ); + + const ShowWithHistory = () => ( +
    + + +
    + ); + + return ( + + } + show={} + /> + + ); +}; /** - * CustomRoutesSupport: a custom page rendered through CustomRoutes. + * Tests that routes match correctly + * Tests resource routes, custom routes, and catch-all routes. */ -const CustomPage = () => { - const location = useLocation(); +export const RouteMatching = () => { + const Dashboard = () => ( +
    +

    Dashboard

    +

    Welcome to the admin dashboard.

    +
      +
    • + Posts +
    • +
    +
    + ); + return ( -
    Custom page at {location.pathname}
    + + + ); }; -export const CustomRoutesSupport = () => ( - - - } /> - - - -); +/** + * 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 ( + + + + ); +}; /** - * UseParamsTest: reads the record id from the URL params. + * Tests navigation between multiple resources */ -const ParamsReader = () => { - const params = useParams(); - return
    {JSON.stringify(params)}
    ; +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 UseParamsTest = () => ( - - - } /> - - - -); +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) + +
    +
    + } + /> +
    + ); +}; /** - * UseMatchTest: matches the current location against a pattern. + * Displays URL parameters extracted from the current route. */ -const MatchReader = () => { - const match = useMatch({ path: '/posts/:id/show' }); - return
    {JSON.stringify(match)}
    ; +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={} + /> +
    + ); }; -export const UseMatchTest = () => ( - - - } /> - - - -); +/** + * 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

    +
    + + } + /> +
    + ); +}; /** - * UseLocationTest: surfaces the current location. + * Blocks navigation when there are unsaved changes. */ -export const UseLocationTest = () => ( - - - -); +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: confirms react-admin detects it is inside a router. + * RouterContextTest: Tests useInRouterContext and useCanBlock hooks */ -const InRouterContextReader = () => { - const inContext = useInRouterContext(); - return
    {String(inContext)}
    ; +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

    + + + } + /> +
    + ); }; -export const RouterContextTest = () => ( +const { Routes, Outlet: RouterOutlet } = reactRouterProvider; + +export const NestedResources = () => ( - - } /> - - + }> + } /> + ); -/** - * UseBlockerTest: blocks navigation while a form is dirty. - */ -const BlockerForm = () => { - const [dirty, setDirty] = React.useState(false); - const blocker = useBlocker(dirty); +const PostEditWithLinkToComments = () => { const navigate = useNavigate(); return ( -
    - - - {blocker.state === 'blocked' ? ( -
    - - + ( +
    +

    Post Details

    + {record &&

    {record.title}

    } + +
    - ) : null} -
    + )} + /> ); }; -export const UseBlockerTest = () => ( +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 + + } + /> + +
    + +
    +
    + ); +}; From 2bbf948cb334f9272896a6a4a59098aa92ce1b01 Mon Sep 17 00:00:00 2001 From: Shaoyu Meng Date: Thu, 25 Jun 2026 00:51:08 -0700 Subject: [PATCH 25/56] test: Mirror ra-router-tanstack spec coverage in ra-router-react-router-next Rewrite the spec to follow the tanstack provider spec structure (full matchPath suite plus all hook/component describes), adapting expectations to react-router's real semantics. The embedded basename deep-navigation case is documented and omitted, as the adapter relies on react-router's native router-level basename. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src/reactRouterNextProvider.spec.tsx | 633 ++++++++++++++++-- .../src/reactRouterNextProvider.stories.tsx | 2 +- 2 files changed, 577 insertions(+), 58 deletions(-) diff --git a/packages/ra-router-react-router-next/src/reactRouterNextProvider.spec.tsx b/packages/ra-router-react-router-next/src/reactRouterNextProvider.spec.tsx index cc7e5d0066f..8339b062267 100644 --- a/packages/ra-router-react-router-next/src/reactRouterNextProvider.spec.tsx +++ b/packages/ra-router-react-router-next/src/reactRouterNextProvider.spec.tsx @@ -1,5 +1,11 @@ import * as React from 'react'; -import { render, screen, waitFor, cleanup } from '@testing-library/react'; +import { + render, + screen, + fireEvent, + waitFor, + cleanup, +} from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { Basic, @@ -28,6 +34,7 @@ import { reactRouterNextProvider } from './reactRouterNextProvider'; const { matchPath } = reactRouterNextProvider; describe('reactRouterNextProvider', () => { + // Reset hash before each test to ensure clean state beforeEach(() => { window.location.hash = ''; }); @@ -38,6 +45,13 @@ describe('reactRouterNextProvider', () => { }); describe('matchPath', () => { + // matchPath here is react-router's own implementation, so its results + // differ from the hand-rolled tanstack matcher in a few documented ways + // (splat values are not prefixed with "/", params are not fully decoded, + // empty and collapsed-slash paths do not match, trailing slashes are + // preserved in `pathname`). The assertions below capture react-router's + // actual behavior and use `toMatchObject` because react-router also + // returns a `pattern` field. describe('catch-all patterns', () => { it('should match "*" against any path', () => { expect(matchPath('*', '/anything')).toMatchObject({ @@ -47,10 +61,26 @@ describe('reactRouterNextProvider', () => { }); }); - it('should match "/*" against a nested path', () => { - expect(matchPath('/*', '/posts/1')).toMatchObject({ - params: { '*': 'posts/1' }, - pathname: '/posts/1', + 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: '/', }); }); @@ -65,13 +95,35 @@ describe('reactRouterNextProvider', () => { }); }); + 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 an exact static path', () => { + it('should match exact static path', () => { expect(matchPath('/posts', '/posts')).toMatchObject({ params: {}, pathname: '/posts', @@ -79,86 +131,408 @@ describe('reactRouterNextProvider', () => { }); }); - it('should not match a static path against a longer path', () => { + 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 a static path as a prefix with end=false', () => { + 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 a single param', () => { - expect(matchPath('/posts/:id', '/posts/1')).toMatchObject({ - params: { id: '1' }, - pathname: '/posts/1', + 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('/:resource/:id/show', '/posts/1/show') + matchPath( + '/users/:userId/posts/:postId', + '/users/1/posts/2' + ) ).toMatchObject({ - params: { resource: 'posts', id: '1' }, - pathname: '/posts/1/show', + params: { userId: '1', postId: '2' }, + pathname: '/users/1/posts/2', + pathnameBase: '/users/1/posts/2', }); }); - it('should not match a param when the segment is missing', () => { + 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(); }); - }); - describe('react-admin resource patterns', () => { - it('should match a resource list', () => { + it('should match param at root level', () => { expect(matchPath('/:resource', '/posts')).toMatchObject({ params: { resource: 'posts' }, + pathname: '/posts', + pathnameBase: '/posts', }); }); - it('should match a resource edit', () => { + it('should not fully decode URL-encoded params (only the path separator)', () => { + // react-router decodes %2F to "/" but leaves the rest 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 the path separator in 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 (pathname already stripped of basename)', () => { - it('should match a path after the basename is stripped', () => { - // basename "/admin" + "/admin/posts" => matchPath sees "/posts" + 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 a catch-all after the basename is stripped', () => { - // "/admin/posts/1" with basename "/admin" => "/posts/1" + 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 a nested resource after the basename is stripped', () => { - // "/admin/posts/1/show" with basename "/admin" => "/posts/1/show" + 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 (Basic)', () => { - it('should render the post list inside its own hash router', async () => { + describe('standalone mode', () => { + it('should render the post list', async () => { render(); await waitFor(() => { expect(screen.getByText('Posts')).toBeInTheDocument(); @@ -175,8 +549,8 @@ describe('reactRouterNextProvider', () => { }); }); - describe('embedded mode (EmbeddedInReactRouter)', () => { - it('should render the host app home page initially', async () => { + describe('embedded mode', () => { + it('should render home page initially', async () => { render(); await waitFor(() => { expect(screen.getByText('Home Page')).toBeInTheDocument(); @@ -188,19 +562,50 @@ describe('reactRouterNextProvider', () => { }); }); - it('should mount react-admin under the basename and navigate to it', async () => { + 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 } + ); }); }); }); @@ -238,6 +643,17 @@ describe('reactRouterNextProvider', () => { expect(screen.getByText('Posts')).toBeInTheDocument(); }); }); + + // NOTE: tanstack's "should navigate within nested routes" test is + // intentionally omitted. It navigates deep inside the embedded admin + // (basename "/admin"). The react-router-next adapter relies on + // react-router's native, router-level basename, which only exists in + // standalone mode; in embedded mode react-admin renders descendant + // routes inside the host router (which has no "/admin" basename), so + // react-admin's internal links resolve outside the basename. Putting + // the basename on the host router is not an option either — it also + // serves the "/" home route. Supporting this would require porting + // tanstack's manual basename handling (custom Link/navigate/Routes). }); describe('Link', () => { @@ -300,6 +716,7 @@ describe('reactRouterNextProvider', () => { await waitFor(() => { expect(screen.getByText('Post Details')).toBeInTheDocument(); }); + // Check that search params are preserved in location expect( screen.getByText(/"search": "\?foo=bar"/) ).toBeInTheDocument(); @@ -319,10 +736,12 @@ describe('reactRouterNextProvider', () => { ); 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(); @@ -338,6 +757,25 @@ describe('reactRouterNextProvider', () => { }); }); + it('should match show routes', async () => { + const user = userEvent.setup(); + render(); + await waitFor(() => { + expect(screen.getByText('Post #1')).toBeInTheDocument(); + }); + + await user.click(screen.getByText('Post #1')); + + await waitFor( + () => { + expect( + screen.getByText('Post Details') + ).toBeInTheDocument(); + }, + { timeout: 3000 } + ); + }); + it('should navigate between resources', async () => { const user = userEvent.setup(); render(); @@ -463,6 +901,29 @@ describe('reactRouterNextProvider', () => { expect(paramsDisplay.textContent).toContain('"id"'); expect(paramsDisplay.textContent).toContain('"1"'); }); + + it('should return different id param for different records', async () => { + const user = userEvent.setup(); + render(); + await waitFor(() => { + expect(screen.getByText('Post #2')).toBeInTheDocument(); + }); + + await user.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', () => { @@ -682,19 +1143,26 @@ describe('reactRouterNextProvider', () => { 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 await user.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(); @@ -766,8 +1234,8 @@ describe('reactRouterNextProvider', () => { }); }); - describe('useInRouterContext / useCanBlock', () => { - it('should report being inside a router', async () => { + describe('useInRouterContext', () => { + it('should return true when inside router', async () => { render(); await waitFor(() => { expect( @@ -779,8 +1247,10 @@ describe('reactRouterNextProvider', () => { screen.getByTestId('in-router-context').textContent ).toContain('true'); }); + }); - it('should report that blocking is supported in a data router', async () => { + describe('useCanBlock', () => { + it('should return true for a data router', async () => { render(); await waitFor(() => { expect( @@ -803,6 +1273,7 @@ describe('reactRouterNextProvider', () => { await user.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( @@ -819,6 +1290,7 @@ describe('reactRouterNextProvider', () => { await user.click(screen.getByText('Post #1')); await screen.findByTestId('content-tab'); + // Click on the second tab (Metadata) await user.click(screen.getByText('Metadata Tab')); await screen.findByTestId('metadata-tab'); @@ -827,6 +1299,43 @@ describe('reactRouterNextProvider', () => { ).toBeInTheDocument(); expect(screen.queryByTestId('content-tab')).not.toBeInTheDocument(); }); + + it('should navigate back to first tab', async () => { + const user = userEvent.setup(); + render(); + await waitFor(() => { + expect(screen.getByText('Post #1')).toBeInTheDocument(); + }); + + await user.click(screen.getByText('Post #1')); + + await waitFor( + () => { + expect( + screen.getByTestId('content-tab') + ).toBeInTheDocument(); + }, + { timeout: 3000 } + ); + + // Go to second tab + await user.click(screen.getByText('Metadata Tab')); + + await waitFor(() => { + expect(screen.getByTestId('metadata-tab')).toBeInTheDocument(); + }); + + // Go back to first tab + await user.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)', () => { @@ -854,10 +1363,12 @@ describe('reactRouterNextProvider', () => { render(); await screen.findByText('Posts with Query Parameters'); + // Initially no search params expect(screen.getByTestId('current-search').textContent).toContain( '(empty)' ); + // Click sort by title await user.click(screen.getByTestId('sort-title')); await waitFor(() => { @@ -876,6 +1387,7 @@ describe('reactRouterNextProvider', () => { render(); await screen.findByText('Posts with Query Parameters'); + // Click page 2 await user.click(screen.getByTestId('page-2')); await waitFor(() => { @@ -894,6 +1406,7 @@ describe('reactRouterNextProvider', () => { render(); await screen.findByText('Posts with Query Parameters'); + // Set sort await user.click(screen.getByTestId('sort-title')); await waitFor(() => { @@ -902,6 +1415,7 @@ describe('reactRouterNextProvider', () => { ).toContain('sort=title'); }); + // Set page await user.click(screen.getByTestId('page-3')); await waitFor(() => { @@ -968,39 +1482,39 @@ describe('reactRouterNextProvider', () => { ).toBeInTheDocument(); }); }); + }); - it('should match the empty path route as most specific within pathless layout routes', async () => { - window.location.hash = '#/posts'; - const user = userEvent.setup(); + it('should match the empty path route as most specific within pathless layout routes', async () => { + window.location.hash = '#/posts'; + const user = userEvent.setup(); - render(); + render(); - await waitFor(() => { - expect(screen.getByTestId('posts-page')).toBeInTheDocument(); - }); + await waitFor(() => { + expect(screen.getByTestId('posts-page')).toBeInTheDocument(); + }); - await user.click(screen.getByText('Home (path="")')); + await user.click(screen.getByText('Home (path="")')); - await waitFor(() => { - expect(screen.getByTestId('home-page')).toBeInTheDocument(); - }); + 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'; - const user = userEvent.setup(); + it('should match the index route as most specific within pathless layout routes', async () => { + window.location.hash = '#/posts'; + const user = userEvent.setup(); - render(); + render(); - await waitFor(() => { - expect(screen.getByTestId('posts-page')).toBeInTheDocument(); - }); + await waitFor(() => { + expect(screen.getByTestId('posts-page')).toBeInTheDocument(); + }); - await user.click(screen.getByText('Home (index)')); + await user.click(screen.getByText('Home (index)')); - await waitFor(() => { - expect(screen.getByTestId('home-page')).toBeInTheDocument(); - }); + await waitFor(() => { + expect(screen.getByTestId('home-page')).toBeInTheDocument(); }); }); @@ -1009,14 +1523,19 @@ describe('reactRouterNextProvider', () => { const user = userEvent.setup(); render(); + // Wait for posts list to load await screen.findByText('Post #1'); + // Click on a post to go to edit page await user.click(screen.getByText('Post #1')); + // Wait for edit page await screen.findByText('Post Details'); + // Click to view comments (child route) await user.click(screen.getByText('View Comments')); + // Should navigate to comments page, not stay on edit await waitFor(() => { expect( screen.getByText(/Comments for Post/) diff --git a/packages/ra-router-react-router-next/src/reactRouterNextProvider.stories.tsx b/packages/ra-router-react-router-next/src/reactRouterNextProvider.stories.tsx index 8d072c61625..aad65222ae6 100644 --- a/packages/ra-router-react-router-next/src/reactRouterNextProvider.stories.tsx +++ b/packages/ra-router-react-router-next/src/reactRouterNextProvider.stories.tsx @@ -226,7 +226,7 @@ const EmbeddedAdmin = () => ( ); // Splat route to handle /admin, /admin/posts, /admin/posts/1/show, etc. -const embeddedAdminRoute = { path: 'admin/*', element: }; +const embeddedAdminRoute = { path: '/admin/*', element: }; const embeddedRouteTree = [ { From aba3a03821709790aa9977cae192ee4e209ec7c7 Mon Sep 17 00:00:00 2001 From: Shaoyu Meng Date: Thu, 25 Jun 2026 00:51:53 -0700 Subject: [PATCH 26/56] test: Mirror the comprehensive provider spec in ra-router-react-router Bring the react-router v6/v7 adapter spec in line with ra-router-react-router-next (full matchPath suite plus all hook/component describes). react-router's matchPath behaves identically here, so all 106 tests pass unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src/reactRouterProvider.spec.tsx | 633 ++++++++++++++++-- 1 file changed, 576 insertions(+), 57 deletions(-) diff --git a/packages/ra-router-react-router/src/reactRouterProvider.spec.tsx b/packages/ra-router-react-router/src/reactRouterProvider.spec.tsx index 64f3335b5a5..580202bd7d4 100644 --- a/packages/ra-router-react-router/src/reactRouterProvider.spec.tsx +++ b/packages/ra-router-react-router/src/reactRouterProvider.spec.tsx @@ -1,5 +1,11 @@ import * as React from 'react'; -import { render, screen, waitFor, cleanup } from '@testing-library/react'; +import { + render, + screen, + fireEvent, + waitFor, + cleanup, +} from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { Basic, @@ -28,6 +34,7 @@ import { reactRouterProvider } from './reactRouterProvider'; const { matchPath } = reactRouterProvider; describe('reactRouterProvider', () => { + // Reset hash before each test to ensure clean state beforeEach(() => { window.location.hash = ''; }); @@ -38,6 +45,13 @@ describe('reactRouterProvider', () => { }); describe('matchPath', () => { + // matchPath here is react-router's own implementation, so its results + // differ from the hand-rolled tanstack matcher in a few documented ways + // (splat values are not prefixed with "/", params are not fully decoded, + // empty and collapsed-slash paths do not match, trailing slashes are + // preserved in `pathname`). The assertions below capture react-router's + // actual behavior and use `toMatchObject` because react-router also + // returns a `pattern` field. describe('catch-all patterns', () => { it('should match "*" against any path', () => { expect(matchPath('*', '/anything')).toMatchObject({ @@ -47,10 +61,26 @@ describe('reactRouterProvider', () => { }); }); - it('should match "/*" against a nested path', () => { - expect(matchPath('/*', '/posts/1')).toMatchObject({ - params: { '*': 'posts/1' }, - pathname: '/posts/1', + 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: '/', }); }); @@ -65,13 +95,35 @@ describe('reactRouterProvider', () => { }); }); + 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 an exact static path', () => { + it('should match exact static path', () => { expect(matchPath('/posts', '/posts')).toMatchObject({ params: {}, pathname: '/posts', @@ -79,86 +131,408 @@ describe('reactRouterProvider', () => { }); }); - it('should not match a static path against a longer path', () => { + 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 a static path as a prefix with end=false', () => { + 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 a single param', () => { - expect(matchPath('/posts/:id', '/posts/1')).toMatchObject({ - params: { id: '1' }, - pathname: '/posts/1', + 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('/:resource/:id/show', '/posts/1/show') + matchPath( + '/users/:userId/posts/:postId', + '/users/1/posts/2' + ) ).toMatchObject({ - params: { resource: 'posts', id: '1' }, - pathname: '/posts/1/show', + params: { userId: '1', postId: '2' }, + pathname: '/users/1/posts/2', + pathnameBase: '/users/1/posts/2', }); }); - it('should not match a param when the segment is missing', () => { + 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(); }); - }); - describe('react-admin resource patterns', () => { - it('should match a resource list', () => { + it('should match param at root level', () => { expect(matchPath('/:resource', '/posts')).toMatchObject({ params: { resource: 'posts' }, + pathname: '/posts', + pathnameBase: '/posts', }); }); - it('should match a resource edit', () => { + it('should not fully decode URL-encoded params (only the path separator)', () => { + // react-router decodes %2F to "/" but leaves the rest 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 the path separator in 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 (pathname already stripped of basename)', () => { - it('should match a path after the basename is stripped', () => { - // basename "/admin" + "/admin/posts" => matchPath sees "/posts" + 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 a catch-all after the basename is stripped', () => { - // "/admin/posts/1" with basename "/admin" => "/posts/1" + 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 a nested resource after the basename is stripped', () => { - // "/admin/posts/1/show" with basename "/admin" => "/posts/1/show" + 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 (Basic)', () => { - it('should render the post list inside its own hash router', async () => { + describe('standalone mode', () => { + it('should render the post list', async () => { render(); await waitFor(() => { expect(screen.getByText('Posts')).toBeInTheDocument(); @@ -175,8 +549,8 @@ describe('reactRouterProvider', () => { }); }); - describe('embedded mode (EmbeddedInReactRouter)', () => { - it('should render the host app home page initially', async () => { + describe('embedded mode', () => { + it('should render home page initially', async () => { render(); await waitFor(() => { expect(screen.getByText('Home Page')).toBeInTheDocument(); @@ -188,19 +562,50 @@ describe('reactRouterProvider', () => { }); }); - it('should mount react-admin under the basename and navigate to it', async () => { + 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 } + ); }); }); }); @@ -238,6 +643,17 @@ describe('reactRouterProvider', () => { expect(screen.getByText('Posts')).toBeInTheDocument(); }); }); + + // NOTE: tanstack's "should navigate within nested routes" test is + // intentionally omitted. It navigates deep inside the embedded admin + // (basename "/admin"). The react-router-next adapter relies on + // react-router's native, router-level basename, which only exists in + // standalone mode; in embedded mode react-admin renders descendant + // routes inside the host router (which has no "/admin" basename), so + // react-admin's internal links resolve outside the basename. Putting + // the basename on the host router is not an option either — it also + // serves the "/" home route. Supporting this would require porting + // tanstack's manual basename handling (custom Link/navigate/Routes). }); describe('Link', () => { @@ -300,6 +716,7 @@ describe('reactRouterProvider', () => { await waitFor(() => { expect(screen.getByText('Post Details')).toBeInTheDocument(); }); + // Check that search params are preserved in location expect( screen.getByText(/"search": "\?foo=bar"/) ).toBeInTheDocument(); @@ -319,10 +736,12 @@ describe('reactRouterProvider', () => { ); 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(); @@ -338,6 +757,25 @@ describe('reactRouterProvider', () => { }); }); + it('should match show routes', async () => { + const user = userEvent.setup(); + render(); + await waitFor(() => { + expect(screen.getByText('Post #1')).toBeInTheDocument(); + }); + + await user.click(screen.getByText('Post #1')); + + await waitFor( + () => { + expect( + screen.getByText('Post Details') + ).toBeInTheDocument(); + }, + { timeout: 3000 } + ); + }); + it('should navigate between resources', async () => { const user = userEvent.setup(); render(); @@ -463,6 +901,29 @@ describe('reactRouterProvider', () => { expect(paramsDisplay.textContent).toContain('"id"'); expect(paramsDisplay.textContent).toContain('"1"'); }); + + it('should return different id param for different records', async () => { + const user = userEvent.setup(); + render(); + await waitFor(() => { + expect(screen.getByText('Post #2')).toBeInTheDocument(); + }); + + await user.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', () => { @@ -682,19 +1143,26 @@ describe('reactRouterProvider', () => { 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 await user.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(); @@ -766,8 +1234,8 @@ describe('reactRouterProvider', () => { }); }); - describe('useInRouterContext / useCanBlock', () => { - it('should report being inside a router', async () => { + describe('useInRouterContext', () => { + it('should return true when inside router', async () => { render(); await waitFor(() => { expect( @@ -779,8 +1247,10 @@ describe('reactRouterProvider', () => { screen.getByTestId('in-router-context').textContent ).toContain('true'); }); + }); - it('should report that blocking is supported in a data router', async () => { + describe('useCanBlock', () => { + it('should return true for a data router', async () => { render(); await waitFor(() => { expect( @@ -803,6 +1273,7 @@ describe('reactRouterProvider', () => { await user.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( @@ -819,6 +1290,7 @@ describe('reactRouterProvider', () => { await user.click(screen.getByText('Post #1')); await screen.findByTestId('content-tab'); + // Click on the second tab (Metadata) await user.click(screen.getByText('Metadata Tab')); await screen.findByTestId('metadata-tab'); @@ -827,6 +1299,43 @@ describe('reactRouterProvider', () => { ).toBeInTheDocument(); expect(screen.queryByTestId('content-tab')).not.toBeInTheDocument(); }); + + it('should navigate back to first tab', async () => { + const user = userEvent.setup(); + render(); + await waitFor(() => { + expect(screen.getByText('Post #1')).toBeInTheDocument(); + }); + + await user.click(screen.getByText('Post #1')); + + await waitFor( + () => { + expect( + screen.getByTestId('content-tab') + ).toBeInTheDocument(); + }, + { timeout: 3000 } + ); + + // Go to second tab + await user.click(screen.getByText('Metadata Tab')); + + await waitFor(() => { + expect(screen.getByTestId('metadata-tab')).toBeInTheDocument(); + }); + + // Go back to first tab + await user.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)', () => { @@ -854,10 +1363,12 @@ describe('reactRouterProvider', () => { render(); await screen.findByText('Posts with Query Parameters'); + // Initially no search params expect(screen.getByTestId('current-search').textContent).toContain( '(empty)' ); + // Click sort by title await user.click(screen.getByTestId('sort-title')); await waitFor(() => { @@ -876,6 +1387,7 @@ describe('reactRouterProvider', () => { render(); await screen.findByText('Posts with Query Parameters'); + // Click page 2 await user.click(screen.getByTestId('page-2')); await waitFor(() => { @@ -894,6 +1406,7 @@ describe('reactRouterProvider', () => { render(); await screen.findByText('Posts with Query Parameters'); + // Set sort await user.click(screen.getByTestId('sort-title')); await waitFor(() => { @@ -902,6 +1415,7 @@ describe('reactRouterProvider', () => { ).toContain('sort=title'); }); + // Set page await user.click(screen.getByTestId('page-3')); await waitFor(() => { @@ -968,39 +1482,39 @@ describe('reactRouterProvider', () => { ).toBeInTheDocument(); }); }); + }); - it('should match the empty path route as most specific within pathless layout routes', async () => { - window.location.hash = '#/posts'; - const user = userEvent.setup(); + it('should match the empty path route as most specific within pathless layout routes', async () => { + window.location.hash = '#/posts'; + const user = userEvent.setup(); - render(); + render(); - await waitFor(() => { - expect(screen.getByTestId('posts-page')).toBeInTheDocument(); - }); + await waitFor(() => { + expect(screen.getByTestId('posts-page')).toBeInTheDocument(); + }); - await user.click(screen.getByText('Home (path="")')); + await user.click(screen.getByText('Home (path="")')); - await waitFor(() => { - expect(screen.getByTestId('home-page')).toBeInTheDocument(); - }); + 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'; - const user = userEvent.setup(); + it('should match the index route as most specific within pathless layout routes', async () => { + window.location.hash = '#/posts'; + const user = userEvent.setup(); - render(); + render(); - await waitFor(() => { - expect(screen.getByTestId('posts-page')).toBeInTheDocument(); - }); + await waitFor(() => { + expect(screen.getByTestId('posts-page')).toBeInTheDocument(); + }); - await user.click(screen.getByText('Home (index)')); + await user.click(screen.getByText('Home (index)')); - await waitFor(() => { - expect(screen.getByTestId('home-page')).toBeInTheDocument(); - }); + await waitFor(() => { + expect(screen.getByTestId('home-page')).toBeInTheDocument(); }); }); @@ -1009,14 +1523,19 @@ describe('reactRouterProvider', () => { const user = userEvent.setup(); render(); + // Wait for posts list to load await screen.findByText('Post #1'); + // Click on a post to go to edit page await user.click(screen.getByText('Post #1')); + // Wait for edit page await screen.findByText('Post Details'); + // Click to view comments (child route) await user.click(screen.getByText('View Comments')); + // Should navigate to comments page, not stay on edit await waitFor(() => { expect( screen.getByText(/Comments for Post/) From 3b1e367804ed53de6798d00b289543778807ef0a Mon Sep 17 00:00:00 2001 From: Shaoyu Meng Date: Thu, 25 Jun 2026 01:17:46 -0700 Subject: [PATCH 27/56] fix: Prepend basename in react-router-next Link/Navigate/useNavigate The adapter relied solely on react-router's native router-level basename, which only exists in standalone mode. In embedded mode react-admin owns the basename (exposed via useBasename) while the host router has none, so internal links escaped the basename and 404'd. Prepend the basename in Link, Navigate, and useNavigate (guarded against double-prepend); native Routes need no change. Restores the embedded nested-navigation test and adds a standalone-with-basename guard test. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src/reactRouterNextProvider.spec.tsx | 43 ++++++++--- .../src/reactRouterNextProvider.stories.tsx | 16 ++++ .../src/reactRouterNextProvider.tsx | 75 ++++++++++++++++++- 3 files changed, 120 insertions(+), 14 deletions(-) diff --git a/packages/ra-router-react-router-next/src/reactRouterNextProvider.spec.tsx b/packages/ra-router-react-router-next/src/reactRouterNextProvider.spec.tsx index 8339b062267..b768e2d01af 100644 --- a/packages/ra-router-react-router-next/src/reactRouterNextProvider.spec.tsx +++ b/packages/ra-router-react-router-next/src/reactRouterNextProvider.spec.tsx @@ -9,6 +9,7 @@ import { import userEvent from '@testing-library/user-event'; import { Basic, + StandaloneWithBasename, EmbeddedInReactRouter, HistoryNavigation, LinkComponent, @@ -547,6 +548,15 @@ describe('reactRouterNextProvider', () => { ).toBeInTheDocument(); }); }); + + it('should not double-prepend the basename when set on a standalone admin', async () => { + window.location.hash = '#/admin/posts'; + render(); + const link = await screen.findByText('Post #1'); + // basename lives on the router, so the link must carry it + // exactly once (not "/admin/admin/..."). + expect(link.getAttribute('href')).toBe('#/admin/posts/1/show'); + }); }); describe('embedded mode', () => { @@ -644,16 +654,29 @@ describe('reactRouterNextProvider', () => { }); }); - // NOTE: tanstack's "should navigate within nested routes" test is - // intentionally omitted. It navigates deep inside the embedded admin - // (basename "/admin"). The react-router-next adapter relies on - // react-router's native, router-level basename, which only exists in - // standalone mode; in embedded mode react-admin renders descendant - // routes inside the host router (which has no "/admin" basename), so - // react-admin's internal links resolve outside the basename. Putting - // the basename on the host router is not an option either — it also - // serves the "/" home route. Supporting this would require porting - // tanstack's manual basename handling (custom Link/navigate/Routes). + it('should navigate within nested routes', async () => { + const user = userEvent.setup(); + render(); + await waitFor(() => { + expect(screen.getByText('Admin')).toBeInTheDocument(); + }); + + await user.click(screen.getByText('Admin')); + + await screen.findByText('Posts'); + await screen.findByText('Post #1'); + + await user.click(screen.getByText('Post #1')); + + await waitFor( + () => { + expect( + screen.getByText('Post Details') + ).toBeInTheDocument(); + }, + { timeout: 3000 } + ); + }); }); describe('Link', () => { diff --git a/packages/ra-router-react-router-next/src/reactRouterNextProvider.stories.tsx b/packages/ra-router-react-router-next/src/reactRouterNextProvider.stories.tsx index aad65222ae6..8f7df0ae2f0 100644 --- a/packages/ra-router-react-router-next/src/reactRouterNextProvider.stories.tsx +++ b/packages/ra-router-react-router-next/src/reactRouterNextProvider.stories.tsx @@ -161,6 +161,22 @@ export const Basic = () => (
    ); +/** + * Standalone mode with a basename: react-admin owns the router and the + * basename is passed to its hash router. Used to verify links are not + * double-prefixed (the basename lives on the router, not in BasenameContext). + */ +export const StandaloneWithBasename = () => ( + + + +); + /** * EmbeddedInReactRouter: Admin inside an existing React Router app * Tests that react-admin detects existing router and uses it. diff --git a/packages/ra-router-react-router-next/src/reactRouterNextProvider.tsx b/packages/ra-router-react-router-next/src/reactRouterNextProvider.tsx index 9e6e0032f5a..6359456a574 100644 --- a/packages/ra-router-react-router-next/src/reactRouterNextProvider.tsx +++ b/packages/ra-router-react-router-next/src/reactRouterNextProvider.tsx @@ -1,5 +1,5 @@ import * as React from 'react'; -import { useContext, useEffect, useRef, ReactNode } from 'react'; +import { useContext, useEffect, useRef, forwardRef, ReactNode } from 'react'; import { useNavigate as useReactRouterNavigate, useLocation, @@ -7,8 +7,8 @@ import { useBlocker, useMatch, useInRouterContext, - Link, - Navigate, + Link as ReactRouterLink, + Navigate as ReactRouterNavigate, Route, Routes, Outlet, @@ -18,12 +18,30 @@ import { UNSAFE_DataRouterContext, UNSAFE_DataRouterStateContext, } from 'react-router'; +import { useBasename } from 'ra-core'; import type { RouterProvider, RouterWrapperProps, RouterNavigateFunction, + RouterLinkProps, + RouterNavigateProps, } from 'ra-core'; +/** + * Prepend the react-admin basename to an absolute path. + * + * In standalone mode react-admin keeps the basename on the router (and + * `useBasename()` returns ''), so this is a no-op. In embedded mode the host + * router owns no basename and `useBasename()` returns it, so links and + * navigation must prepend it themselves. The guard avoids double-prepending + * paths that already include the basename (e.g. those built by `useCreatePath`). + */ +const prependBasename = (path: string, basename: string): string => { + if (!basename || !path.startsWith('/')) return path; + if (path === basename || path.startsWith(`${basename}/`)) return path; + return `${basename}${path}`; +}; + /** * Hook to check if navigation blocking is supported. * In react-router, blocking requires a data router. @@ -45,9 +63,12 @@ const useCanBlock = (): boolean => { */ const useNavigate = (): RouterNavigateFunction => { const navigate = useReactRouterNavigate(); + const basename = useBasename(); const navigateRef = useRef( navigate as RouterNavigateFunction ); + const basenameRef = useRef(basename); + basenameRef.current = basename; useEffect(() => { navigateRef.current = navigate as RouterNavigateFunction; @@ -55,10 +76,56 @@ const useNavigate = (): RouterNavigateFunction => { // Return a stable function that always calls the latest navigate return React.useCallback((...args: Parameters) => { - return navigateRef.current(...args); + const [to, ...rest] = args; + const bn = basenameRef.current; + if (typeof to === 'string') { + return navigateRef.current(prependBasename(to, bn), ...rest); + } + if (to && typeof to === 'object' && 'pathname' in to && to.pathname) { + return navigateRef.current( + { ...to, pathname: prependBasename(to.pathname, bn) }, + ...rest + ); + } + return navigateRef.current(to, ...rest); }, []) as RouterNavigateFunction; }; +/** + * Wrapper around react-router's Link that prepends the react-admin basename + * to absolute paths (see prependBasename). This makes links work both in + * standalone mode (no-op) and embedded mode (basename owned by react-admin). + */ +const Link = forwardRef( + ({ to, ...rest }, ref) => { + const basename = useBasename(); + const resolvedTo = + typeof to === 'string' + ? prependBasename(to, basename) + : to && typeof to === 'object' && to.pathname + ? { ...to, pathname: prependBasename(to.pathname, basename) } + : to; + return ; + } +); +Link.displayName = 'Link'; + +/** + * Wrapper around react-router's Navigate that prepends the react-admin + * basename to absolute paths, mirroring the Link behavior for declarative + * redirects. + */ +const Navigate = ({ to, ...rest }: RouterNavigateProps) => { + const basename = useBasename(); + const resolvedTo = + typeof to === 'string' + ? prependBasename(to, basename) + : to && typeof to === 'object' && to.pathname + ? { ...to, pathname: prependBasename(to.pathname, basename) } + : to; + return ; +}; + /** * Internal router component that creates a HashRouter. * Only used when not already inside a router context. From 42ff222d4a2573bdcb66a98288627f48607dcd79 Mon Sep 17 00:00:00 2001 From: Shaoyu Meng Date: Thu, 25 Jun 2026 01:22:38 -0700 Subject: [PATCH 28/56] fix: Prepend basename in react-router Link/Navigate/useNavigate Mirror the react-router-next basename fix in the default v6/v7 adapter so embedded react-admin (mounted under a basename) resolves internal links correctly. Because ra-core depends on this package, it cannot import ra-core's useBasename (circular build dependency); instead RouterWrapper publishes the basename via a provider-local context that Link, Navigate, and useNavigate read. Standalone mode keeps the basename on the hash router and leaves the context empty to avoid double-prepending. Adds the embedded nested-navigation and standalone-with-basename tests. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src/reactRouterProvider.spec.tsx | 43 +++++-- .../src/reactRouterProvider.stories.tsx | 16 +++ .../src/reactRouterProvider.tsx | 110 +++++++++++++++++- 3 files changed, 153 insertions(+), 16 deletions(-) diff --git a/packages/ra-router-react-router/src/reactRouterProvider.spec.tsx b/packages/ra-router-react-router/src/reactRouterProvider.spec.tsx index 580202bd7d4..2430c8e8ff8 100644 --- a/packages/ra-router-react-router/src/reactRouterProvider.spec.tsx +++ b/packages/ra-router-react-router/src/reactRouterProvider.spec.tsx @@ -9,6 +9,7 @@ import { import userEvent from '@testing-library/user-event'; import { Basic, + StandaloneWithBasename, EmbeddedInReactRouter, HistoryNavigation, LinkComponent, @@ -547,6 +548,15 @@ describe('reactRouterProvider', () => { ).toBeInTheDocument(); }); }); + + it('should not double-prepend the basename when set on a standalone admin', async () => { + window.location.hash = '#/admin/posts'; + render(); + const link = await screen.findByText('Post #1'); + // basename lives on the router, so the link must carry it + // exactly once (not "/admin/admin/..."). + expect(link.getAttribute('href')).toBe('#/admin/posts/1/show'); + }); }); describe('embedded mode', () => { @@ -644,16 +654,29 @@ describe('reactRouterProvider', () => { }); }); - // NOTE: tanstack's "should navigate within nested routes" test is - // intentionally omitted. It navigates deep inside the embedded admin - // (basename "/admin"). The react-router-next adapter relies on - // react-router's native, router-level basename, which only exists in - // standalone mode; in embedded mode react-admin renders descendant - // routes inside the host router (which has no "/admin" basename), so - // react-admin's internal links resolve outside the basename. Putting - // the basename on the host router is not an option either — it also - // serves the "/" home route. Supporting this would require porting - // tanstack's manual basename handling (custom Link/navigate/Routes). + it('should navigate within nested routes', async () => { + const user = userEvent.setup(); + render(); + await waitFor(() => { + expect(screen.getByText('Admin')).toBeInTheDocument(); + }); + + await user.click(screen.getByText('Admin')); + + await screen.findByText('Posts'); + await screen.findByText('Post #1'); + + await user.click(screen.getByText('Post #1')); + + await waitFor( + () => { + expect( + screen.getByText('Post Details') + ).toBeInTheDocument(); + }, + { timeout: 3000 } + ); + }); }); describe('Link', () => { diff --git a/packages/ra-router-react-router/src/reactRouterProvider.stories.tsx b/packages/ra-router-react-router/src/reactRouterProvider.stories.tsx index 32d5b8977e2..1c7a6a1b540 100644 --- a/packages/ra-router-react-router/src/reactRouterProvider.stories.tsx +++ b/packages/ra-router-react-router/src/reactRouterProvider.stories.tsx @@ -160,6 +160,22 @@ export const Basic = () => (
    ); +/** + * Standalone mode with a basename: react-admin owns the router and the + * basename is passed to its hash router. Used to verify links are not + * double-prefixed (the basename lives on the router, not in BasenameContext). + */ +export const StandaloneWithBasename = () => ( + + + +); + /** * EmbeddedInReactRouter: Admin inside an existing React Router app * Tests that react-admin detects existing router and uses it. diff --git a/packages/ra-router-react-router/src/reactRouterProvider.tsx b/packages/ra-router-react-router/src/reactRouterProvider.tsx index 3d68f76aeb5..3622b52acaf 100644 --- a/packages/ra-router-react-router/src/reactRouterProvider.tsx +++ b/packages/ra-router-react-router/src/reactRouterProvider.tsx @@ -1,5 +1,12 @@ import * as React from 'react'; -import { useContext, useEffect, useRef, ReactNode } from 'react'; +import { + useContext, + useEffect, + useRef, + forwardRef, + createContext, + ReactNode, +} from 'react'; import { useNavigate as useReactRouterNavigate, useLocation, @@ -7,7 +14,7 @@ import { useBlocker, useMatch, useInRouterContext, - Navigate, + Navigate as ReactRouterNavigate, Route, Routes, Outlet, @@ -16,8 +23,13 @@ import { UNSAFE_DataRouterContext, UNSAFE_DataRouterStateContext, type FutureConfig, + type NavigateProps, } from 'react-router'; -import { Link, createHashRouter } from 'react-router-dom'; +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), @@ -44,6 +56,33 @@ const routerProviderFuture: Partial< Pick > = { v7_startTransition: false, v7_relativeSplatPath: false }; +/** + * Provider-local basename context. + * + * react-admin exposes the basename through ra-core's `useBasename`, but this + * package cannot import ra-core (it would create a circular build dependency: + * ra-core -> ra-router-react-router -> ra-core). Instead, RouterWrapper — which + * receives the basename from react-admin's AdminRouter — publishes it here, and + * Link/Navigate/useNavigate read it to prepend the basename to absolute paths. + * + * It mirrors AdminRouter's own logic: empty in standalone mode (the basename + * lives on the hash router) and the real basename in embedded mode (the host + * router owns no basename, so links must carry it themselves). + */ +const BasenameContext = createContext(''); +const useProviderBasename = (): string => useContext(BasenameContext); + +/** + * Prepend the basename to an absolute path, guarded against double-prepending + * paths that already include it (e.g. those built by react-admin's + * `useCreatePath`). + */ +const prependBasename = (path: string, basename: string): string => { + if (!basename || !path.startsWith('/')) return path; + if (path === basename || path.startsWith(`${basename}/`)) return path; + return `${basename}${path}`; +}; + /** * Hook to check if navigation blocking is supported. * In react-router, blocking requires a data router. @@ -65,9 +104,12 @@ const useCanBlock = (): boolean => { */ const useNavigate = (): RouterNavigateFunction => { const navigate = useReactRouterNavigate(); + const basename = useProviderBasename(); const navigateRef = useRef( navigate as RouterNavigateFunction ); + const basenameRef = useRef(basename); + basenameRef.current = basename; useEffect(() => { navigateRef.current = navigate as RouterNavigateFunction; @@ -75,10 +117,54 @@ const useNavigate = (): RouterNavigateFunction => { // Return a stable function that always calls the latest navigate return React.useCallback((...args: Parameters) => { - return navigateRef.current(...args); + const [to, ...rest] = args; + const bn = basenameRef.current; + if (typeof to === 'string') { + return navigateRef.current(prependBasename(to, bn), ...rest); + } + if (to && typeof to === 'object' && 'pathname' in to && to.pathname) { + return navigateRef.current( + { ...to, pathname: prependBasename(to.pathname, bn) }, + ...rest + ); + } + return navigateRef.current(to, ...rest); }, []) as RouterNavigateFunction; }; +/** + * Wrapper around react-router-dom's Link that prepends the react-admin + * basename to absolute paths (see prependBasename and BasenameContext). + */ +const Link = forwardRef( + ({ to, ...rest }, ref) => { + const basename = useProviderBasename(); + const resolvedTo = + typeof to === 'string' + ? prependBasename(to, basename) + : to && typeof to === 'object' && to.pathname + ? { ...to, pathname: prependBasename(to.pathname, basename) } + : to; + return ; + } +); +Link.displayName = 'Link'; + +/** + * Wrapper around react-router's Navigate that prepends the react-admin + * basename to absolute paths, mirroring the Link behavior. + */ +const Navigate = ({ to, ...rest }: NavigateProps) => { + const basename = useProviderBasename(); + const resolvedTo = + typeof to === 'string' + ? prependBasename(to, basename) + : to && typeof to === 'object' && to.pathname + ? { ...to, pathname: prependBasename(to.pathname, basename) } + : to; + return ; +}; + /** * Internal router component that creates a HashRouter. * Only used when not already inside a router context. @@ -113,10 +199,22 @@ const RouterWrapper = ({ basename, children }: RouterWrapperProps) => { const isInRouter = useInRouterContext(); if (isInRouter) { - return <>{children}; + // Embedded mode: the host router owns no basename, so publish it for + // Link/Navigate/useNavigate to prepend. + return ( + + {children} + + ); } - return {children}; + // Standalone mode: the hash router carries the basename natively, so the + // provider-local basename stays empty to avoid double-prepending. + return ( + + {children} + + ); }; /** From 12d951cd3e9d1976df0c8f956347e3a8612e6987 Mon Sep 17 00:00:00 2001 From: Shaoyu Meng Date: Thu, 25 Jun 2026 15:18:00 -0700 Subject: [PATCH 29/56] test: Drop StandaloneWithBasename story from the router adapters The story files must stay as mirrored (only the embeddedAdminRoute "/admin/*" tweak is allowed). Remove the StandaloneWithBasename story and its dedicated double-prepend test from both adapters; the basename provider fix and the embedded nested-navigation test remain. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src/reactRouterNextProvider.spec.tsx | 10 ---------- .../src/reactRouterNextProvider.stories.tsx | 16 ---------------- .../src/reactRouterProvider.spec.tsx | 10 ---------- .../src/reactRouterProvider.stories.tsx | 18 +----------------- 4 files changed, 1 insertion(+), 53 deletions(-) diff --git a/packages/ra-router-react-router-next/src/reactRouterNextProvider.spec.tsx b/packages/ra-router-react-router-next/src/reactRouterNextProvider.spec.tsx index b768e2d01af..cc9f01ad82b 100644 --- a/packages/ra-router-react-router-next/src/reactRouterNextProvider.spec.tsx +++ b/packages/ra-router-react-router-next/src/reactRouterNextProvider.spec.tsx @@ -9,7 +9,6 @@ import { import userEvent from '@testing-library/user-event'; import { Basic, - StandaloneWithBasename, EmbeddedInReactRouter, HistoryNavigation, LinkComponent, @@ -548,15 +547,6 @@ describe('reactRouterNextProvider', () => { ).toBeInTheDocument(); }); }); - - it('should not double-prepend the basename when set on a standalone admin', async () => { - window.location.hash = '#/admin/posts'; - render(); - const link = await screen.findByText('Post #1'); - // basename lives on the router, so the link must carry it - // exactly once (not "/admin/admin/..."). - expect(link.getAttribute('href')).toBe('#/admin/posts/1/show'); - }); }); describe('embedded mode', () => { diff --git a/packages/ra-router-react-router-next/src/reactRouterNextProvider.stories.tsx b/packages/ra-router-react-router-next/src/reactRouterNextProvider.stories.tsx index 8f7df0ae2f0..aad65222ae6 100644 --- a/packages/ra-router-react-router-next/src/reactRouterNextProvider.stories.tsx +++ b/packages/ra-router-react-router-next/src/reactRouterNextProvider.stories.tsx @@ -161,22 +161,6 @@ export const Basic = () => (
    ); -/** - * Standalone mode with a basename: react-admin owns the router and the - * basename is passed to its hash router. Used to verify links are not - * double-prefixed (the basename lives on the router, not in BasenameContext). - */ -export const StandaloneWithBasename = () => ( - - - -); - /** * EmbeddedInReactRouter: Admin inside an existing React Router app * Tests that react-admin detects existing router and uses it. diff --git a/packages/ra-router-react-router/src/reactRouterProvider.spec.tsx b/packages/ra-router-react-router/src/reactRouterProvider.spec.tsx index 2430c8e8ff8..f7d235150f4 100644 --- a/packages/ra-router-react-router/src/reactRouterProvider.spec.tsx +++ b/packages/ra-router-react-router/src/reactRouterProvider.spec.tsx @@ -9,7 +9,6 @@ import { import userEvent from '@testing-library/user-event'; import { Basic, - StandaloneWithBasename, EmbeddedInReactRouter, HistoryNavigation, LinkComponent, @@ -548,15 +547,6 @@ describe('reactRouterProvider', () => { ).toBeInTheDocument(); }); }); - - it('should not double-prepend the basename when set on a standalone admin', async () => { - window.location.hash = '#/admin/posts'; - render(); - const link = await screen.findByText('Post #1'); - // basename lives on the router, so the link must carry it - // exactly once (not "/admin/admin/..."). - expect(link.getAttribute('href')).toBe('#/admin/posts/1/show'); - }); }); describe('embedded mode', () => { diff --git a/packages/ra-router-react-router/src/reactRouterProvider.stories.tsx b/packages/ra-router-react-router/src/reactRouterProvider.stories.tsx index 1c7a6a1b540..20934c9ffa7 100644 --- a/packages/ra-router-react-router/src/reactRouterProvider.stories.tsx +++ b/packages/ra-router-react-router/src/reactRouterProvider.stories.tsx @@ -160,22 +160,6 @@ export const Basic = () => (
    ); -/** - * Standalone mode with a basename: react-admin owns the router and the - * basename is passed to its hash router. Used to verify links are not - * double-prefixed (the basename lives on the router, not in BasenameContext). - */ -export const StandaloneWithBasename = () => ( - - - -); - /** * EmbeddedInReactRouter: Admin inside an existing React Router app * Tests that react-admin detects existing router and uses it. @@ -241,7 +225,7 @@ const EmbeddedAdmin = () => ( ); // Splat route to handle /admin, /admin/posts, /admin/posts/1/show, etc. -const embeddedAdminRoute = { path: 'admin/*', element: }; +const embeddedAdminRoute = { path: '/admin/*', element: }; const embeddedRouteTree = [ { From 1345d92029d83ac609aba6aa20fea9345d322ea0 Mon Sep 17 00:00:00 2001 From: Shaoyu Meng Date: Thu, 25 Jun 2026 15:30:40 -0700 Subject: [PATCH 30/56] make path relative --- .../src/reactRouterNextProvider.stories.tsx | 2 +- .../ra-router-react-router/src/reactRouterProvider.stories.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/ra-router-react-router-next/src/reactRouterNextProvider.stories.tsx b/packages/ra-router-react-router-next/src/reactRouterNextProvider.stories.tsx index aad65222ae6..8d072c61625 100644 --- a/packages/ra-router-react-router-next/src/reactRouterNextProvider.stories.tsx +++ b/packages/ra-router-react-router-next/src/reactRouterNextProvider.stories.tsx @@ -226,7 +226,7 @@ const EmbeddedAdmin = () => ( ); // Splat route to handle /admin, /admin/posts, /admin/posts/1/show, etc. -const embeddedAdminRoute = { path: '/admin/*', element: }; +const embeddedAdminRoute = { path: 'admin/*', element: }; const embeddedRouteTree = [ { diff --git a/packages/ra-router-react-router/src/reactRouterProvider.stories.tsx b/packages/ra-router-react-router/src/reactRouterProvider.stories.tsx index 20934c9ffa7..32d5b8977e2 100644 --- a/packages/ra-router-react-router/src/reactRouterProvider.stories.tsx +++ b/packages/ra-router-react-router/src/reactRouterProvider.stories.tsx @@ -225,7 +225,7 @@ const EmbeddedAdmin = () => ( ); // Splat route to handle /admin, /admin/posts, /admin/posts/1/show, etc. -const embeddedAdminRoute = { path: '/admin/*', element: }; +const embeddedAdminRoute = { path: 'admin/*', element: }; const embeddedRouteTree = [ { From d35e95898ce99817d83f2b43a07f10a032bed747 Mon Sep 17 00:00:00 2001 From: Shaoyu Meng Date: Thu, 25 Jun 2026 16:49:22 -0700 Subject: [PATCH 31/56] refactor: Use tanstack's inline resolvePath for basename in router adapters Replace the module-level prependBasename helper with the same inline resolvePath helper the tanstack adapter defines inside Link/useNavigate (same body, same guard order, same comment), and use it in Link, Navigate, and useNavigate. Behavior is unchanged; this keeps the three router adapters structurally aligned for easier comparison. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src/reactRouterNextProvider.tsx | 63 ++++++++++++------- .../src/reactRouterProvider.tsx | 57 +++++++++++------ 2 files changed, 77 insertions(+), 43 deletions(-) diff --git a/packages/ra-router-react-router-next/src/reactRouterNextProvider.tsx b/packages/ra-router-react-router-next/src/reactRouterNextProvider.tsx index 6359456a574..fcbd0e1abf2 100644 --- a/packages/ra-router-react-router-next/src/reactRouterNextProvider.tsx +++ b/packages/ra-router-react-router-next/src/reactRouterNextProvider.tsx @@ -27,21 +27,6 @@ import type { RouterNavigateProps, } from 'ra-core'; -/** - * Prepend the react-admin basename to an absolute path. - * - * In standalone mode react-admin keeps the basename on the router (and - * `useBasename()` returns ''), so this is a no-op. In embedded mode the host - * router owns no basename and `useBasename()` returns it, so links and - * navigation must prepend it themselves. The guard avoids double-prepending - * paths that already include the basename (e.g. those built by `useCreatePath`). - */ -const prependBasename = (path: string, basename: string): string => { - if (!basename || !path.startsWith('/')) return path; - if (path === basename || path.startsWith(`${basename}/`)) return path; - return `${basename}${path}`; -}; - /** * Hook to check if navigation blocking is supported. * In react-router, blocking requires a data router. @@ -77,13 +62,23 @@ const useNavigate = (): RouterNavigateFunction => { // Return a stable function that always calls the latest navigate return React.useCallback((...args: Parameters) => { const [to, ...rest] = args; - const bn = basenameRef.current; + const basename = basenameRef.current; + + // 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}`; + }; + if (typeof to === 'string') { - return navigateRef.current(prependBasename(to, bn), ...rest); + return navigateRef.current(resolvePath(to), ...rest); } if (to && typeof to === 'object' && 'pathname' in to && to.pathname) { return navigateRef.current( - { ...to, pathname: prependBasename(to.pathname, bn) }, + { ...to, pathname: resolvePath(to.pathname) }, ...rest ); } @@ -93,17 +88,27 @@ const useNavigate = (): RouterNavigateFunction => { /** * Wrapper around react-router's Link that prepends the react-admin basename - * to absolute paths (see prependBasename). This makes links work both in - * standalone mode (no-op) and embedded mode (basename owned by react-admin). + * to absolute paths. This makes links work both in standalone mode (no-op) + * and embedded mode (basename owned by react-admin). */ 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}`; + }; + const resolvedTo = typeof to === 'string' - ? prependBasename(to, basename) + ? resolvePath(to) : to && typeof to === 'object' && to.pathname - ? { ...to, pathname: prependBasename(to.pathname, basename) } + ? { ...to, pathname: resolvePath(to.pathname) } : to; return ; } @@ -117,11 +122,21 @@ Link.displayName = 'Link'; */ const Navigate = ({ to, ...rest }: RouterNavigateProps) => { 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}`; + }; + const resolvedTo = typeof to === 'string' - ? prependBasename(to, basename) + ? resolvePath(to) : to && typeof to === 'object' && to.pathname - ? { ...to, pathname: prependBasename(to.pathname, basename) } + ? { ...to, pathname: resolvePath(to.pathname) } : to; return ; }; diff --git a/packages/ra-router-react-router/src/reactRouterProvider.tsx b/packages/ra-router-react-router/src/reactRouterProvider.tsx index 3622b52acaf..e3b040ea16e 100644 --- a/packages/ra-router-react-router/src/reactRouterProvider.tsx +++ b/packages/ra-router-react-router/src/reactRouterProvider.tsx @@ -72,17 +72,6 @@ const routerProviderFuture: Partial< const BasenameContext = createContext(''); const useProviderBasename = (): string => useContext(BasenameContext); -/** - * Prepend the basename to an absolute path, guarded against double-prepending - * paths that already include it (e.g. those built by react-admin's - * `useCreatePath`). - */ -const prependBasename = (path: string, basename: string): string => { - if (!basename || !path.startsWith('/')) return path; - if (path === basename || path.startsWith(`${basename}/`)) return path; - return `${basename}${path}`; -}; - /** * Hook to check if navigation blocking is supported. * In react-router, blocking requires a data router. @@ -118,13 +107,23 @@ const useNavigate = (): RouterNavigateFunction => { // Return a stable function that always calls the latest navigate return React.useCallback((...args: Parameters) => { const [to, ...rest] = args; - const bn = basenameRef.current; + const basename = basenameRef.current; + + // 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}`; + }; + if (typeof to === 'string') { - return navigateRef.current(prependBasename(to, bn), ...rest); + return navigateRef.current(resolvePath(to), ...rest); } if (to && typeof to === 'object' && 'pathname' in to && to.pathname) { return navigateRef.current( - { ...to, pathname: prependBasename(to.pathname, bn) }, + { ...to, pathname: resolvePath(to.pathname) }, ...rest ); } @@ -134,16 +133,26 @@ const useNavigate = (): RouterNavigateFunction => { /** * Wrapper around react-router-dom's Link that prepends the react-admin - * basename to absolute paths (see prependBasename and BasenameContext). + * basename to absolute paths (see BasenameContext). */ const Link = forwardRef( ({ to, ...rest }, ref) => { const basename = useProviderBasename(); + + // 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}`; + }; + const resolvedTo = typeof to === 'string' - ? prependBasename(to, basename) + ? resolvePath(to) : to && typeof to === 'object' && to.pathname - ? { ...to, pathname: prependBasename(to.pathname, basename) } + ? { ...to, pathname: resolvePath(to.pathname) } : to; return ; } @@ -156,11 +165,21 @@ Link.displayName = 'Link'; */ const Navigate = ({ to, ...rest }: NavigateProps) => { const basename = useProviderBasename(); + + // 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}`; + }; + const resolvedTo = typeof to === 'string' - ? prependBasename(to, basename) + ? resolvePath(to) : to && typeof to === 'object' && to.pathname - ? { ...to, pathname: prependBasename(to.pathname, basename) } + ? { ...to, pathname: resolvePath(to.pathname) } : to; return ; }; From c4b0b0f4996953b6d575ce8f708ca8340138078b Mon Sep 17 00:00:00 2001 From: Shaoyu Meng Date: Thu, 25 Jun 2026 16:54:49 -0700 Subject: [PATCH 32/56] refactor: Match tanstack's useNavigate resolvePath comments verbatim The resolvePath helper inside useNavigate carries a two-line lead comment plus an inner "Don't prepend if path already includes basename" comment in the tanstack adapter, unlike the one-line form inside Link. Align both router adapters so each call site matches its tanstack counterpart exactly. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../ra-router-react-router-next/src/reactRouterNextProvider.tsx | 2 ++ packages/ra-router-react-router/src/reactRouterProvider.tsx | 2 ++ 2 files changed, 4 insertions(+) diff --git a/packages/ra-router-react-router-next/src/reactRouterNextProvider.tsx b/packages/ra-router-react-router-next/src/reactRouterNextProvider.tsx index fcbd0e1abf2..cd47e06cf95 100644 --- a/packages/ra-router-react-router-next/src/reactRouterNextProvider.tsx +++ b/packages/ra-router-react-router-next/src/reactRouterNextProvider.tsx @@ -65,8 +65,10 @@ const useNavigate = (): RouterNavigateFunction => { const basename = basenameRef.current; // 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; } diff --git a/packages/ra-router-react-router/src/reactRouterProvider.tsx b/packages/ra-router-react-router/src/reactRouterProvider.tsx index e3b040ea16e..bc9399ff86a 100644 --- a/packages/ra-router-react-router/src/reactRouterProvider.tsx +++ b/packages/ra-router-react-router/src/reactRouterProvider.tsx @@ -110,8 +110,10 @@ const useNavigate = (): RouterNavigateFunction => { const basename = basenameRef.current; // 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; } From ffa03ca328fa2c920868c17379d0c132df089d3d Mon Sep 17 00:00:00 2001 From: Shaoyu Meng Date: Thu, 25 Jun 2026 17:00:16 -0700 Subject: [PATCH 33/56] refactor: Read basename directly in useNavigate like the tanstack adapter Drop the extra basenameRef; useNavigate now reads basename from the hook and lists it in the useCallback dependency array, matching the tanstack adapter's shape. The navigateRef (react-router-specific, issue #7634) stays. Behavior unchanged; 107/107 in both adapters. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src/reactRouterNextProvider.tsx | 59 ++++++++++--------- .../src/reactRouterProvider.tsx | 57 ++++++++++-------- 2 files changed, 63 insertions(+), 53 deletions(-) diff --git a/packages/ra-router-react-router-next/src/reactRouterNextProvider.tsx b/packages/ra-router-react-router-next/src/reactRouterNextProvider.tsx index cd47e06cf95..0a74cf19710 100644 --- a/packages/ra-router-react-router-next/src/reactRouterNextProvider.tsx +++ b/packages/ra-router-react-router-next/src/reactRouterNextProvider.tsx @@ -52,40 +52,45 @@ const useNavigate = (): RouterNavigateFunction => { const navigateRef = useRef( navigate as RouterNavigateFunction ); - const basenameRef = useRef(basename); - basenameRef.current = basename; 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; - const basename = basenameRef.current; - - // 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 React.useCallback( + (...args: Parameters) => { + const [to, ...rest] = args; + + // 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}`; + }; + + if (typeof to === 'string') { + return navigateRef.current(resolvePath(to), ...rest); } - return `${basename}${path}`; - }; - - if (typeof to === 'string') { - return navigateRef.current(resolvePath(to), ...rest); - } - if (to && typeof to === 'object' && 'pathname' in to && to.pathname) { - return navigateRef.current( - { ...to, pathname: resolvePath(to.pathname) }, - ...rest - ); - } - return navigateRef.current(to, ...rest); - }, []) as RouterNavigateFunction; + if ( + to && + typeof to === 'object' && + 'pathname' in to && + to.pathname + ) { + return navigateRef.current( + { ...to, pathname: resolvePath(to.pathname) }, + ...rest + ); + } + return navigateRef.current(to, ...rest); + }, + [basename] + ) as RouterNavigateFunction; }; /** diff --git a/packages/ra-router-react-router/src/reactRouterProvider.tsx b/packages/ra-router-react-router/src/reactRouterProvider.tsx index bc9399ff86a..1a270566ef7 100644 --- a/packages/ra-router-react-router/src/reactRouterProvider.tsx +++ b/packages/ra-router-react-router/src/reactRouterProvider.tsx @@ -97,40 +97,45 @@ const useNavigate = (): RouterNavigateFunction => { const navigateRef = useRef( navigate as RouterNavigateFunction ); - const basenameRef = useRef(basename); - basenameRef.current = basename; 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; - const basename = basenameRef.current; + return React.useCallback( + (...args: Parameters) => { + const [to, ...rest] = args; - // 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}`; - }; + // 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}`; + }; - if (typeof to === 'string') { - return navigateRef.current(resolvePath(to), ...rest); - } - if (to && typeof to === 'object' && 'pathname' in to && to.pathname) { - return navigateRef.current( - { ...to, pathname: resolvePath(to.pathname) }, - ...rest - ); - } - return navigateRef.current(to, ...rest); - }, []) as RouterNavigateFunction; + if (typeof to === 'string') { + return navigateRef.current(resolvePath(to), ...rest); + } + if ( + to && + typeof to === 'object' && + 'pathname' in to && + to.pathname + ) { + return navigateRef.current( + { ...to, pathname: resolvePath(to.pathname) }, + ...rest + ); + } + return navigateRef.current(to, ...rest); + }, + [basename] + ) as RouterNavigateFunction; }; /** From 095e88783b79bac92dd8a4ea6a1ff7938b05d5a4 Mon Sep 17 00:00:00 2001 From: Shaoyu Meng Date: Thu, 25 Jun 2026 17:09:49 -0700 Subject: [PATCH 34/56] refactor: Mirror tanstack useNavigate branch structure in router adapters MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow the tanstack adapter's useNavigate skeleton exactly: numeric (go back/forward) branch first, then the resolvePath helper, then the object branch, then the string path — with the same comments. Only the call differs (navigateRef.current(to) instead of router.history.go(to)) since react-router's navigate handles all three input shapes. Keeps the three router adapters diff-aligned. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src/reactRouterNextProvider.tsx | 27 +++++++++++-------- .../src/reactRouterProvider.tsx | 27 +++++++++++-------- 2 files changed, 32 insertions(+), 22 deletions(-) diff --git a/packages/ra-router-react-router-next/src/reactRouterNextProvider.tsx b/packages/ra-router-react-router-next/src/reactRouterNextProvider.tsx index 0a74cf19710..6343ac74e5c 100644 --- a/packages/ra-router-react-router-next/src/reactRouterNextProvider.tsx +++ b/packages/ra-router-react-router-next/src/reactRouterNextProvider.tsx @@ -62,6 +62,11 @@ const useNavigate = (): RouterNavigateFunction => { (...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) => { @@ -73,21 +78,21 @@ const useNavigate = (): RouterNavigateFunction => { return `${basename}${path}`; }; - if (typeof to === 'string') { - return navigateRef.current(resolvePath(to), ...rest); - } - if ( - to && - typeof to === 'object' && - 'pathname' in to && - to.pathname - ) { + // 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: resolvePath(to.pathname) }, + to.pathname + ? { ...to, pathname: resolvePath(to.pathname) } + : to, ...rest ); } - return navigateRef.current(to, ...rest); + + // Handle string path + const resolvedPath = resolvePath(to as string); + return navigateRef.current(resolvedPath, ...rest); }, [basename] ) as RouterNavigateFunction; diff --git a/packages/ra-router-react-router/src/reactRouterProvider.tsx b/packages/ra-router-react-router/src/reactRouterProvider.tsx index 1a270566ef7..054330b38fc 100644 --- a/packages/ra-router-react-router/src/reactRouterProvider.tsx +++ b/packages/ra-router-react-router/src/reactRouterProvider.tsx @@ -107,6 +107,11 @@ const useNavigate = (): RouterNavigateFunction => { (...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) => { @@ -118,21 +123,21 @@ const useNavigate = (): RouterNavigateFunction => { return `${basename}${path}`; }; - if (typeof to === 'string') { - return navigateRef.current(resolvePath(to), ...rest); - } - if ( - to && - typeof to === 'object' && - 'pathname' in to && - to.pathname - ) { + // 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: resolvePath(to.pathname) }, + to.pathname + ? { ...to, pathname: resolvePath(to.pathname) } + : to, ...rest ); } - return navigateRef.current(to, ...rest); + + // Handle string path + const resolvedPath = resolvePath(to as string); + return navigateRef.current(resolvedPath, ...rest); }, [basename] ) as RouterNavigateFunction; From b72677814becd362689e869c8761ece814d27243 Mon Sep 17 00:00:00 2001 From: Shaoyu Meng Date: Thu, 25 Jun 2026 17:19:21 -0700 Subject: [PATCH 35/56] refactor: Use tanstack's if/else resolvedTo shape in router adapter Link Match the tanstack adapter's Link: branch on the object form first (`if (typeof to === 'object' && to !== null)`) and fall back to `resolvePath(to as string)` in the else, instead of a nested ternary. Behavior unchanged; 107/107 in both adapters. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src/reactRouterNextProvider.tsx | 21 +++++++++---------- .../src/reactRouterProvider.tsx | 20 +++++++++--------- 2 files changed, 20 insertions(+), 21 deletions(-) diff --git a/packages/ra-router-react-router-next/src/reactRouterNextProvider.tsx b/packages/ra-router-react-router-next/src/reactRouterNextProvider.tsx index 6343ac74e5c..57651716137 100644 --- a/packages/ra-router-react-router-next/src/reactRouterNextProvider.tsx +++ b/packages/ra-router-react-router-next/src/reactRouterNextProvider.tsx @@ -98,11 +98,6 @@ const useNavigate = (): RouterNavigateFunction => { ) as RouterNavigateFunction; }; -/** - * Wrapper around react-router's Link that prepends the react-admin basename - * to absolute paths. This makes links work both in standalone mode (no-op) - * and embedded mode (basename owned by react-admin). - */ const Link = forwardRef( ({ to, ...rest }, ref) => { const basename = useBasename(); @@ -116,12 +111,16 @@ const Link = forwardRef( return `${basename}${path}`; }; - const resolvedTo = - typeof to === 'string' - ? resolvePath(to) - : to && typeof to === 'object' && to.pathname - ? { ...to, pathname: resolvePath(to.pathname) } - : to; + // 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 ; } ); diff --git a/packages/ra-router-react-router/src/reactRouterProvider.tsx b/packages/ra-router-react-router/src/reactRouterProvider.tsx index 054330b38fc..b20a4036290 100644 --- a/packages/ra-router-react-router/src/reactRouterProvider.tsx +++ b/packages/ra-router-react-router/src/reactRouterProvider.tsx @@ -143,10 +143,6 @@ const useNavigate = (): RouterNavigateFunction => { ) as RouterNavigateFunction; }; -/** - * Wrapper around react-router-dom's Link that prepends the react-admin - * basename to absolute paths (see BasenameContext). - */ const Link = forwardRef( ({ to, ...rest }, ref) => { const basename = useProviderBasename(); @@ -160,12 +156,16 @@ const Link = forwardRef( return `${basename}${path}`; }; - const resolvedTo = - typeof to === 'string' - ? resolvePath(to) - : to && typeof to === 'object' && to.pathname - ? { ...to, pathname: resolvePath(to.pathname) } - : to; + // 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 ; } ); From 9ad07c5faad0133b3a939d12eca20483a9f112ff Mon Sep 17 00:00:00 2001 From: Shaoyu Meng Date: Thu, 25 Jun 2026 17:26:43 -0700 Subject: [PATCH 36/56] refactor: Mirror tanstack Navigate string-building in router adapters MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Build a resolvedPath string via the tanstack Navigate shape (string vs object branch, append search/hash, then the inline basename-prepend block) and render . Drops the to.state merge since react-router's Navigate `to` (a Path) has no state field — state stays a prop. 107/107 in both adapters. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src/reactRouterNextProvider.tsx | 61 ++++++++++++------- .../src/reactRouterProvider.tsx | 58 ++++++++++++------ 2 files changed, 79 insertions(+), 40 deletions(-) diff --git a/packages/ra-router-react-router-next/src/reactRouterNextProvider.tsx b/packages/ra-router-react-router-next/src/reactRouterNextProvider.tsx index 57651716137..2387cb6575d 100644 --- a/packages/ra-router-react-router-next/src/reactRouterNextProvider.tsx +++ b/packages/ra-router-react-router-next/src/reactRouterNextProvider.tsx @@ -126,30 +126,49 @@ const Link = forwardRef( ); Link.displayName = 'Link'; -/** - * Wrapper around react-router's Navigate that prepends the react-admin - * basename to absolute paths, mirroring the Link behavior for declarative - * redirects. - */ -const Navigate = ({ to, ...rest }: RouterNavigateProps) => { +const Navigate = ({ to, replace, state }: 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}`; + } + } - // 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; + // Prepend basename to the path (like react-router does) + // Only prepend if path doesn't already start with basename + if (basename && resolvedPath.startsWith('/')) { + if ( + !resolvedPath.startsWith(basename + '/') && + resolvedPath !== basename + ) { + resolvedPath = `${basename}${resolvedPath}`; } - return `${basename}${path}`; - }; - - const resolvedTo = - typeof to === 'string' - ? resolvePath(to) - : to && typeof to === 'object' && to.pathname - ? { ...to, pathname: resolvePath(to.pathname) } - : to; - return ; + } + + return ( + + ); }; /** diff --git a/packages/ra-router-react-router/src/reactRouterProvider.tsx b/packages/ra-router-react-router/src/reactRouterProvider.tsx index b20a4036290..1cec276ae9e 100644 --- a/packages/ra-router-react-router/src/reactRouterProvider.tsx +++ b/packages/ra-router-react-router/src/reactRouterProvider.tsx @@ -171,29 +171,49 @@ const Link = forwardRef( ); Link.displayName = 'Link'; -/** - * Wrapper around react-router's Navigate that prepends the react-admin - * basename to absolute paths, mirroring the Link behavior. - */ -const Navigate = ({ to, ...rest }: NavigateProps) => { +const Navigate = ({ to, replace, state }: NavigateProps) => { const basename = useProviderBasename(); + const currentLocation = useLocation(); + + // Handle both string and object forms of `to` + let resolvedPath: string; - // 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; + 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}`; } - return `${basename}${path}`; - }; + } - const resolvedTo = - typeof to === 'string' - ? resolvePath(to) - : to && typeof to === 'object' && to.pathname - ? { ...to, pathname: resolvePath(to.pathname) } - : to; - return ; + // Prepend basename to the path (like react-router does) + // Only prepend if path doesn't already start with basename + if (basename && resolvedPath.startsWith('/')) { + if ( + !resolvedPath.startsWith(basename + '/') && + resolvedPath !== basename + ) { + resolvedPath = `${basename}${resolvedPath}`; + } + } + + return ( + + ); }; /** From e021aa0014d29c89165d38487546f6bb3a260a0b Mon Sep 17 00:00:00 2001 From: Shaoyu Meng Date: Thu, 25 Jun 2026 17:37:53 -0700 Subject: [PATCH 37/56] refactor: Spread remaining Navigate props in router adapters Pass through the rest of the Navigate props ({...rest}) instead of listing replace/state explicitly, and drop the "(like react-router does)" aside from the basename comment. 107/107 in both adapters. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src/reactRouterNextProvider.tsx | 12 +++--------- .../src/reactRouterProvider.tsx | 12 +++--------- 2 files changed, 6 insertions(+), 18 deletions(-) diff --git a/packages/ra-router-react-router-next/src/reactRouterNextProvider.tsx b/packages/ra-router-react-router-next/src/reactRouterNextProvider.tsx index 2387cb6575d..67e6851c770 100644 --- a/packages/ra-router-react-router-next/src/reactRouterNextProvider.tsx +++ b/packages/ra-router-react-router-next/src/reactRouterNextProvider.tsx @@ -126,7 +126,7 @@ const Link = forwardRef( ); Link.displayName = 'Link'; -const Navigate = ({ to, replace, state }: RouterNavigateProps) => { +const Navigate = ({ to, ...rest }: RouterNavigateProps) => { const basename = useBasename(); const currentLocation = useLocation(); @@ -151,7 +151,7 @@ const Navigate = ({ to, replace, state }: RouterNavigateProps) => { } } - // Prepend basename to the path (like react-router does) + // Prepend basename to the path // Only prepend if path doesn't already start with basename if (basename && resolvedPath.startsWith('/')) { if ( @@ -162,13 +162,7 @@ const Navigate = ({ to, replace, state }: RouterNavigateProps) => { } } - return ( - - ); + return ; }; /** diff --git a/packages/ra-router-react-router/src/reactRouterProvider.tsx b/packages/ra-router-react-router/src/reactRouterProvider.tsx index 1cec276ae9e..62f96d51486 100644 --- a/packages/ra-router-react-router/src/reactRouterProvider.tsx +++ b/packages/ra-router-react-router/src/reactRouterProvider.tsx @@ -171,7 +171,7 @@ const Link = forwardRef( ); Link.displayName = 'Link'; -const Navigate = ({ to, replace, state }: NavigateProps) => { +const Navigate = ({ to, ...rest }: NavigateProps) => { const basename = useProviderBasename(); const currentLocation = useLocation(); @@ -196,7 +196,7 @@ const Navigate = ({ to, replace, state }: NavigateProps) => { } } - // Prepend basename to the path (like react-router does) + // Prepend basename to the path // Only prepend if path doesn't already start with basename if (basename && resolvedPath.startsWith('/')) { if ( @@ -207,13 +207,7 @@ const Navigate = ({ to, replace, state }: NavigateProps) => { } } - return ( - - ); + return ; }; /** From 4f36a8438a172fd42ff431417a314e4e0b934a16 Mon Sep 17 00:00:00 2001 From: Shaoyu Meng Date: Thu, 25 Jun 2026 18:10:27 -0700 Subject: [PATCH 38/56] refactor: Rename provider basename hook and align RouterWrapper context Rename the provider-local useProviderBasename to useBasename and publish the basename via BasenameContext in both RouterWrapper branches. Fix quote style to satisfy prettier. 107/107 in the react-router adapter. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src/reactRouterProvider.tsx | 35 ++++++------------- 1 file changed, 11 insertions(+), 24 deletions(-) diff --git a/packages/ra-router-react-router/src/reactRouterProvider.tsx b/packages/ra-router-react-router/src/reactRouterProvider.tsx index 62f96d51486..42eba312913 100644 --- a/packages/ra-router-react-router/src/reactRouterProvider.tsx +++ b/packages/ra-router-react-router/src/reactRouterProvider.tsx @@ -52,26 +52,17 @@ type UseParams = < >, >() => 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 }; -/** - * Provider-local basename context. - * - * react-admin exposes the basename through ra-core's `useBasename`, but this - * package cannot import ra-core (it would create a circular build dependency: - * ra-core -> ra-router-react-router -> ra-core). Instead, RouterWrapper — which - * receives the basename from react-admin's AdminRouter — publishes it here, and - * Link/Navigate/useNavigate read it to prepend the basename to absolute paths. - * - * It mirrors AdminRouter's own logic: empty in standalone mode (the basename - * lives on the hash router) and the real basename in embedded mode (the host - * router owns no basename, so links must carry it themselves). - */ -const BasenameContext = createContext(''); -const useProviderBasename = (): string => useContext(BasenameContext); - /** * Hook to check if navigation blocking is supported. * In react-router, blocking requires a data router. @@ -93,7 +84,7 @@ const useCanBlock = (): boolean => { */ const useNavigate = (): RouterNavigateFunction => { const navigate = useReactRouterNavigate(); - const basename = useProviderBasename(); + const basename = useBasename(); const navigateRef = useRef( navigate as RouterNavigateFunction ); @@ -145,7 +136,7 @@ const useNavigate = (): RouterNavigateFunction => { const Link = forwardRef( ({ to, ...rest }, ref) => { - const basename = useProviderBasename(); + const basename = useBasename(); // Helper to prepend basename to absolute paths const resolvePath = (path: string) => { @@ -172,7 +163,7 @@ const Link = forwardRef( Link.displayName = 'Link'; const Navigate = ({ to, ...rest }: NavigateProps) => { - const basename = useProviderBasename(); + const basename = useBasename(); const currentLocation = useLocation(); // Handle both string and object forms of `to` @@ -244,8 +235,6 @@ const RouterWrapper = ({ basename, children }: RouterWrapperProps) => { const isInRouter = useInRouterContext(); if (isInRouter) { - // Embedded mode: the host router owns no basename, so publish it for - // Link/Navigate/useNavigate to prepend. return ( {children} @@ -253,10 +242,8 @@ const RouterWrapper = ({ basename, children }: RouterWrapperProps) => { ); } - // Standalone mode: the hash router carries the basename natively, so the - // provider-local basename stays empty to avoid double-prepending. return ( - + {children} ); From 621082062fe3b533c63fc493ba8cc91eb45dc06a Mon Sep 17 00:00:00 2001 From: Shaoyu Meng Date: Thu, 25 Jun 2026 18:23:20 -0700 Subject: [PATCH 39/56] test: Unify click mechanics with the tanstack spec (fireEvent.click) Use fireEvent.click for navigation in both router adapter specs, matching the tanstack provider spec's convention, and keep userEvent.setup/user.type only in the two unsaved-changes form tests that need typing. 107/107 in both adapters. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src/reactRouterNextProvider.spec.tsx | 131 +++++++----------- .../src/reactRouterProvider.spec.tsx | 131 +++++++----------- 2 files changed, 96 insertions(+), 166 deletions(-) diff --git a/packages/ra-router-react-router-next/src/reactRouterNextProvider.spec.tsx b/packages/ra-router-react-router-next/src/reactRouterNextProvider.spec.tsx index cc9f01ad82b..939021cf789 100644 --- a/packages/ra-router-react-router-next/src/reactRouterNextProvider.spec.tsx +++ b/packages/ra-router-react-router-next/src/reactRouterNextProvider.spec.tsx @@ -579,13 +579,12 @@ describe('reactRouterNextProvider', () => { }); 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')); + fireEvent.click(screen.getByText('Admin')); await waitFor( () => { @@ -612,13 +611,12 @@ describe('reactRouterNextProvider', () => { describe('useNavigate', () => { it('should navigate to a path programmatically', async () => { - const user = userEvent.setup(); render(); await waitFor(() => { expect(screen.getByText('Create')).toBeInTheDocument(); }); - await user.click(screen.getByText('Create')); + fireEvent.click(screen.getByText('Create')); await waitFor(() => { expect(screen.getByText('Create Post')).toBeInTheDocument(); @@ -626,18 +624,17 @@ describe('reactRouterNextProvider', () => { }); it('should navigate back in history with navigate(-1)', async () => { - const user = userEvent.setup(); render(); await screen.findByText('Post #1'); - await user.click(screen.getByText('Post #1')); + fireEvent.click(screen.getByText('Post #1')); await waitFor(() => { expect(screen.getByText('Post Details')).toBeInTheDocument(); }); - await user.click(screen.getByText('← Back')); + fireEvent.click(screen.getByText('← Back')); await waitFor(() => { expect(screen.getByText('Posts')).toBeInTheDocument(); @@ -645,18 +642,17 @@ describe('reactRouterNextProvider', () => { }); it('should navigate within nested routes', async () => { - const user = userEvent.setup(); render(); await waitFor(() => { expect(screen.getByText('Admin')).toBeInTheDocument(); }); - await user.click(screen.getByText('Admin')); + fireEvent.click(screen.getByText('Admin')); await screen.findByText('Posts'); await screen.findByText('Post #1'); - await user.click(screen.getByText('Post #1')); + fireEvent.click(screen.getByText('Post #1')); await waitFor( () => { @@ -679,13 +675,12 @@ describe('reactRouterNextProvider', () => { }); it('should navigate when clicked', async () => { - const user = userEvent.setup(); render(); await waitFor(() => { expect(screen.getByText('Post #1')).toBeInTheDocument(); }); - await user.click(screen.getByText('Post #1')); + fireEvent.click(screen.getByText('Post #1')); await waitFor( () => { @@ -716,7 +711,6 @@ describe('reactRouterNextProvider', () => { }); it('should support location object with pathname and search', async () => { - const user = userEvent.setup(); render(); await waitFor(() => { expect( @@ -724,7 +718,7 @@ describe('reactRouterNextProvider', () => { ).toBeInTheDocument(); }); - await user.click(screen.getByText('Go to Post #4 (with search)')); + fireEvent.click(screen.getByText('Go to Post #4 (with search)')); await waitFor(() => { expect(screen.getByText('Post Details')).toBeInTheDocument(); @@ -736,7 +730,6 @@ describe('reactRouterNextProvider', () => { }); it('should support location object with only search (no pathname)', async () => { - const user = userEvent.setup(); render(); await waitFor(() => { expect( @@ -744,7 +737,7 @@ describe('reactRouterNextProvider', () => { ).toBeInTheDocument(); }); - await user.click( + fireEvent.click( screen.getByText('Go to same page with search param') ); @@ -771,13 +764,12 @@ describe('reactRouterNextProvider', () => { }); it('should match show routes', async () => { - const user = userEvent.setup(); render(); await waitFor(() => { expect(screen.getByText('Post #1')).toBeInTheDocument(); }); - await user.click(screen.getByText('Post #1')); + fireEvent.click(screen.getByText('Post #1')); await waitFor( () => { @@ -790,19 +782,18 @@ describe('reactRouterNextProvider', () => { }); it('should navigate between resources', async () => { - const user = userEvent.setup(); render(); await waitFor(() => { expect(screen.getByText('Posts')).toBeInTheDocument(); }); - await user.click(screen.getByText('Go to Comments')); + fireEvent.click(screen.getByText('Go to Comments')); await waitFor(() => { expect(screen.getByText('Comments')).toBeInTheDocument(); }); - await user.click(screen.getByText('Go to Posts')); + fireEvent.click(screen.getByText('Go to Posts')); await waitFor(() => { expect(screen.getByText('Posts')).toBeInTheDocument(); @@ -812,7 +803,6 @@ describe('reactRouterNextProvider', () => { describe('custom routes', () => { it('should render custom routes with layout', async () => { - const user = userEvent.setup(); render(); await waitFor(() => { expect( @@ -820,7 +810,7 @@ describe('reactRouterNextProvider', () => { ).toBeInTheDocument(); }); - await user.click(screen.getByText('Go to Custom Page')); + fireEvent.click(screen.getByText('Go to Custom Page')); await waitFor(() => { expect(screen.getByText('Custom Page')).toBeInTheDocument(); @@ -833,7 +823,6 @@ describe('reactRouterNextProvider', () => { }); it('should render custom routes without layout', async () => { - const user = userEvent.setup(); render(); await waitFor(() => { expect( @@ -841,7 +830,7 @@ describe('reactRouterNextProvider', () => { ).toBeInTheDocument(); }); - await user.click( + fireEvent.click( screen.getByText('Go to Custom Page (No Layout)') ); @@ -858,7 +847,6 @@ describe('reactRouterNextProvider', () => { }); it('should navigate from custom route back to resource', async () => { - const user = userEvent.setup(); render(); await waitFor(() => { expect( @@ -866,13 +854,13 @@ describe('reactRouterNextProvider', () => { ).toBeInTheDocument(); }); - await user.click(screen.getByText('Go to Custom Page')); + fireEvent.click(screen.getByText('Go to Custom Page')); await waitFor(() => { expect(screen.getByText('Custom Page')).toBeInTheDocument(); }); - await user.click(screen.getByText('Go to Posts')); + fireEvent.click(screen.getByText('Go to Posts')); await waitFor(() => { expect(screen.getByText('Posts')).toBeInTheDocument(); @@ -893,13 +881,12 @@ describe('reactRouterNextProvider', () => { }); it('should return id param on show page', async () => { - const user = userEvent.setup(); render(); await waitFor(() => { expect(screen.getByText('Post #1')).toBeInTheDocument(); }); - await user.click(screen.getByText('Post #1')); + fireEvent.click(screen.getByText('Post #1')); await waitFor( () => { @@ -916,13 +903,12 @@ describe('reactRouterNextProvider', () => { }); it('should return different id param for different records', async () => { - const user = userEvent.setup(); render(); await waitFor(() => { expect(screen.getByText('Post #2')).toBeInTheDocument(); }); - await user.click(screen.getByText('Post #2')); + fireEvent.click(screen.getByText('Post #2')); await waitFor( () => { @@ -966,13 +952,12 @@ describe('reactRouterNextProvider', () => { }); it('should not match exact route on nested path', async () => { - const user = userEvent.setup(); render(); await waitFor(() => { expect(screen.getByText('Post #1')).toBeInTheDocument(); }); - await user.click(screen.getByText('Post #1')); + fireEvent.click(screen.getByText('Post #1')); await waitFor( () => { @@ -992,13 +977,12 @@ describe('reactRouterNextProvider', () => { }); it('should update match when navigating to different resource', async () => { - const user = userEvent.setup(); render(); await waitFor(() => { expect(screen.getByText('Posts List')).toBeInTheDocument(); }); - await user.click(screen.getByText('Comments')); + fireEvent.click(screen.getByText('Comments')); await waitFor(() => { expect(screen.getByText('Comments List')).toBeInTheDocument(); @@ -1027,7 +1011,7 @@ describe('reactRouterNextProvider', () => { render(); const [title] = await screen.findAllByRole('textbox'); await user.type(title, 'A new title'); - await user.click(screen.getByText('Go to Comments')); + fireEvent.click(screen.getByText('Go to Comments')); await waitFor(() => { expect(confirmCalled).toBe(true); }); @@ -1052,7 +1036,7 @@ describe('reactRouterNextProvider', () => { render(); const [title] = await screen.findAllByRole('textbox'); await user.type(title, 'A new title'); - await user.click(screen.getByText('Go to Comments')); + fireEvent.click(screen.getByText('Go to Comments')); await waitFor(() => { expect( screen.getByText('You navigated away from the form.') @@ -1071,10 +1055,9 @@ describe('reactRouterNextProvider', () => { return true; }; try { - const user = userEvent.setup(); render(); await screen.findByText('Form with Unsaved Changes Warning'); - await user.click(screen.getByText('Go to Comments')); + fireEvent.click(screen.getByText('Go to Comments')); await waitFor(() => { expect( screen.getByText('You navigated away from the form.') @@ -1089,13 +1072,12 @@ describe('reactRouterNextProvider', () => { describe('Navigate', () => { it('should redirect to target route', async () => { - const user = userEvent.setup(); render(); await waitFor(() => { expect(screen.getByTestId('posts-page')).toBeInTheDocument(); }); - await user.click( + fireEvent.click( screen.getByText('Go to Redirect Page (auto-redirects here)') ); @@ -1106,13 +1088,12 @@ describe('reactRouterNextProvider', () => { }); it('should preserve search params on redirect', async () => { - const user = userEvent.setup(); render(); await waitFor(() => { expect(screen.getByTestId('posts-page')).toBeInTheDocument(); }); - await user.click(screen.getByText('Go to redirect with params')); + fireEvent.click(screen.getByText('Go to redirect with params')); // Should immediately redirect back to posts with search params await waitFor(() => { @@ -1128,13 +1109,12 @@ describe('reactRouterNextProvider', () => { }); it('should redirect conditionally when state changes', async () => { - const user = userEvent.setup(); render(); await waitFor(() => { expect(screen.getByTestId('posts-page')).toBeInTheDocument(); }); - await user.click(screen.getByText('Go to Conditional Redirect')); + fireEvent.click(screen.getByText('Go to Conditional Redirect')); await waitFor(() => { expect( @@ -1142,7 +1122,7 @@ describe('reactRouterNextProvider', () => { ).toBeInTheDocument(); }); - await user.click(screen.getByTestId('trigger-redirect')); + fireEvent.click(screen.getByTestId('trigger-redirect')); await waitFor(() => { expect(screen.getByTestId('posts-page')).toBeInTheDocument(); @@ -1150,7 +1130,6 @@ describe('reactRouterNextProvider', () => { }); it('should support location object with only search (no pathname)', async () => { - const user = userEvent.setup(); render(); await waitFor(() => { expect(screen.getByTestId('posts-page')).toBeInTheDocument(); @@ -1159,7 +1138,7 @@ describe('reactRouterNextProvider', () => { // Navigate to the Navigate search-only test page // This page uses // which should stay on the same pathname but add search params - await user.click( + fireEvent.click( screen.getByText('Go to Navigate search-only test') ); @@ -1206,13 +1185,12 @@ describe('reactRouterNextProvider', () => { }); it('should update pathname on navigation', async () => { - const user = userEvent.setup(); render(); await waitFor(() => { expect(screen.getByText('Go to Post Show')).toBeInTheDocument(); }); - await user.click(screen.getByText('Go to Post Show')); + fireEvent.click(screen.getByText('Go to Post Show')); await waitFor(() => { expect(screen.getByText('Post Show')).toBeInTheDocument(); @@ -1224,7 +1202,6 @@ describe('reactRouterNextProvider', () => { }); it('should include state when navigated with state', async () => { - const user = userEvent.setup(); render(); await waitFor(() => { expect( @@ -1232,7 +1209,7 @@ describe('reactRouterNextProvider', () => { ).toBeInTheDocument(); }); - await user.click(screen.getByText('Go to Post Show (with state)')); + fireEvent.click(screen.getByText('Go to Post Show (with state)')); await waitFor(() => { expect(screen.getByText('Post Show')).toBeInTheDocument(); @@ -1279,11 +1256,10 @@ describe('reactRouterNextProvider', () => { describe('Nested Routes with Outlet', () => { it('should render the default tab content', async () => { - const user = userEvent.setup(); render(); await screen.findByText('Post #1'); - await user.click(screen.getByText('Post #1')); + fireEvent.click(screen.getByText('Post #1')); await screen.findByText('Tabbed Layout (like TabbedShowLayout)'); // Should render the first tab (content) by default @@ -1296,15 +1272,14 @@ describe('reactRouterNextProvider', () => { }); it('should navigate between tabs using Outlet', async () => { - const user = userEvent.setup(); render(); await screen.findByText('Post #1'); - await user.click(screen.getByText('Post #1')); + fireEvent.click(screen.getByText('Post #1')); await screen.findByTestId('content-tab'); // Click on the second tab (Metadata) - await user.click(screen.getByText('Metadata Tab')); + fireEvent.click(screen.getByText('Metadata Tab')); await screen.findByTestId('metadata-tab'); expect( @@ -1314,13 +1289,12 @@ describe('reactRouterNextProvider', () => { }); it('should navigate back to first tab', async () => { - const user = userEvent.setup(); render(); await waitFor(() => { expect(screen.getByText('Post #1')).toBeInTheDocument(); }); - await user.click(screen.getByText('Post #1')); + fireEvent.click(screen.getByText('Post #1')); await waitFor( () => { @@ -1332,14 +1306,14 @@ describe('reactRouterNextProvider', () => { ); // Go to second tab - await user.click(screen.getByText('Metadata Tab')); + fireEvent.click(screen.getByText('Metadata Tab')); await waitFor(() => { expect(screen.getByTestId('metadata-tab')).toBeInTheDocument(); }); // Go back to first tab - await user.click(screen.getByText('Content Tab')); + fireEvent.click(screen.getByText('Content Tab')); await waitFor(() => { expect(screen.getByTestId('content-tab')).toBeInTheDocument(); @@ -1353,11 +1327,10 @@ describe('reactRouterNextProvider', () => { describe('Nested Resources (Route children of Resource)', () => { it('should navigate to child routes defined inside Resource', async () => { - const user = userEvent.setup(); render(); await screen.findByText('Post #1'); - await user.click(screen.getByText('Post #1')); + fireEvent.click(screen.getByText('Post #1')); await waitFor( () => { @@ -1372,7 +1345,6 @@ describe('reactRouterNextProvider', () => { describe('Query Parameters', () => { it('should update URL with query parameters when sorting', async () => { - const user = userEvent.setup(); render(); await screen.findByText('Posts with Query Parameters'); @@ -1382,7 +1354,7 @@ describe('reactRouterNextProvider', () => { ); // Click sort by title - await user.click(screen.getByTestId('sort-title')); + fireEvent.click(screen.getByTestId('sort-title')); await waitFor(() => { expect( @@ -1396,12 +1368,11 @@ describe('reactRouterNextProvider', () => { }); it('should update URL with query parameters when changing page', async () => { - const user = userEvent.setup(); render(); await screen.findByText('Posts with Query Parameters'); // Click page 2 - await user.click(screen.getByTestId('page-2')); + fireEvent.click(screen.getByTestId('page-2')); await waitFor(() => { expect( @@ -1415,12 +1386,11 @@ describe('reactRouterNextProvider', () => { }); it('should preserve query parameters across multiple updates', async () => { - const user = userEvent.setup(); render(); await screen.findByText('Posts with Query Parameters'); // Set sort - await user.click(screen.getByTestId('sort-title')); + fireEvent.click(screen.getByTestId('sort-title')); await waitFor(() => { expect( @@ -1429,7 +1399,7 @@ describe('reactRouterNextProvider', () => { }); // Set page - await user.click(screen.getByTestId('page-3')); + fireEvent.click(screen.getByTestId('page-3')); await waitFor(() => { const search = @@ -1454,7 +1424,6 @@ describe('reactRouterNextProvider', () => { it('should navigate between child routes within pathless layout', async () => { window.location.hash = '#/posts'; - const user = userEvent.setup(); render(); @@ -1463,7 +1432,7 @@ describe('reactRouterNextProvider', () => { expect(screen.getByTestId('posts-page')).toBeInTheDocument(); }); - await user.click(screen.getByText('Comments')); + fireEvent.click(screen.getByText('Comments')); await waitFor(() => { expect(screen.getByText('Layout Wrapper')).toBeInTheDocument(); @@ -1473,7 +1442,6 @@ describe('reactRouterNextProvider', () => { it('should match the most specific layout route within pathless layout routes', async () => { window.location.hash = '#/posts'; - const user = userEvent.setup(); render(); @@ -1481,13 +1449,13 @@ describe('reactRouterNextProvider', () => { expect(screen.getByTestId('posts-page')).toBeInTheDocument(); }); - await user.click(screen.getByText('User')); + fireEvent.click(screen.getByText('User')); await waitFor(() => { expect(screen.getByTestId('users-page')).toBeInTheDocument(); }); - await user.click(screen.getByText('Block a user')); + fireEvent.click(screen.getByText('Block a user')); await waitFor(() => { expect( @@ -1499,7 +1467,6 @@ describe('reactRouterNextProvider', () => { it('should match the empty path route as most specific within pathless layout routes', async () => { window.location.hash = '#/posts'; - const user = userEvent.setup(); render(); @@ -1507,7 +1474,7 @@ describe('reactRouterNextProvider', () => { expect(screen.getByTestId('posts-page')).toBeInTheDocument(); }); - await user.click(screen.getByText('Home (path="")')); + fireEvent.click(screen.getByText('Home (path="")')); await waitFor(() => { expect(screen.getByTestId('home-page')).toBeInTheDocument(); @@ -1516,7 +1483,6 @@ describe('reactRouterNextProvider', () => { it('should match the index route as most specific within pathless layout routes', async () => { window.location.hash = '#/posts'; - const user = userEvent.setup(); render(); @@ -1524,7 +1490,7 @@ describe('reactRouterNextProvider', () => { expect(screen.getByTestId('posts-page')).toBeInTheDocument(); }); - await user.click(screen.getByText('Home (index)')); + fireEvent.click(screen.getByText('Home (index)')); await waitFor(() => { expect(screen.getByTestId('home-page')).toBeInTheDocument(); @@ -1533,20 +1499,19 @@ describe('reactRouterNextProvider', () => { describe('Resource Children (Route as children of Resource)', () => { it('should navigate to child routes without matching parent edit route', async () => { - const user = userEvent.setup(); render(); // Wait for posts list to load await screen.findByText('Post #1'); // Click on a post to go to edit page - await user.click(screen.getByText('Post #1')); + fireEvent.click(screen.getByText('Post #1')); // Wait for edit page await screen.findByText('Post Details'); // Click to view comments (child route) - await user.click(screen.getByText('View Comments')); + fireEvent.click(screen.getByText('View Comments')); // Should navigate to comments page, not stay on edit await waitFor(() => { diff --git a/packages/ra-router-react-router/src/reactRouterProvider.spec.tsx b/packages/ra-router-react-router/src/reactRouterProvider.spec.tsx index f7d235150f4..d549f612743 100644 --- a/packages/ra-router-react-router/src/reactRouterProvider.spec.tsx +++ b/packages/ra-router-react-router/src/reactRouterProvider.spec.tsx @@ -579,13 +579,12 @@ describe('reactRouterProvider', () => { }); 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')); + fireEvent.click(screen.getByText('Admin')); await waitFor( () => { @@ -612,13 +611,12 @@ describe('reactRouterProvider', () => { describe('useNavigate', () => { it('should navigate to a path programmatically', async () => { - const user = userEvent.setup(); render(); await waitFor(() => { expect(screen.getByText('Create')).toBeInTheDocument(); }); - await user.click(screen.getByText('Create')); + fireEvent.click(screen.getByText('Create')); await waitFor(() => { expect(screen.getByText('Create Post')).toBeInTheDocument(); @@ -626,18 +624,17 @@ describe('reactRouterProvider', () => { }); it('should navigate back in history with navigate(-1)', async () => { - const user = userEvent.setup(); render(); await screen.findByText('Post #1'); - await user.click(screen.getByText('Post #1')); + fireEvent.click(screen.getByText('Post #1')); await waitFor(() => { expect(screen.getByText('Post Details')).toBeInTheDocument(); }); - await user.click(screen.getByText('← Back')); + fireEvent.click(screen.getByText('← Back')); await waitFor(() => { expect(screen.getByText('Posts')).toBeInTheDocument(); @@ -645,18 +642,17 @@ describe('reactRouterProvider', () => { }); it('should navigate within nested routes', async () => { - const user = userEvent.setup(); render(); await waitFor(() => { expect(screen.getByText('Admin')).toBeInTheDocument(); }); - await user.click(screen.getByText('Admin')); + fireEvent.click(screen.getByText('Admin')); await screen.findByText('Posts'); await screen.findByText('Post #1'); - await user.click(screen.getByText('Post #1')); + fireEvent.click(screen.getByText('Post #1')); await waitFor( () => { @@ -679,13 +675,12 @@ describe('reactRouterProvider', () => { }); it('should navigate when clicked', async () => { - const user = userEvent.setup(); render(); await waitFor(() => { expect(screen.getByText('Post #1')).toBeInTheDocument(); }); - await user.click(screen.getByText('Post #1')); + fireEvent.click(screen.getByText('Post #1')); await waitFor( () => { @@ -716,7 +711,6 @@ describe('reactRouterProvider', () => { }); it('should support location object with pathname and search', async () => { - const user = userEvent.setup(); render(); await waitFor(() => { expect( @@ -724,7 +718,7 @@ describe('reactRouterProvider', () => { ).toBeInTheDocument(); }); - await user.click(screen.getByText('Go to Post #4 (with search)')); + fireEvent.click(screen.getByText('Go to Post #4 (with search)')); await waitFor(() => { expect(screen.getByText('Post Details')).toBeInTheDocument(); @@ -736,7 +730,6 @@ describe('reactRouterProvider', () => { }); it('should support location object with only search (no pathname)', async () => { - const user = userEvent.setup(); render(); await waitFor(() => { expect( @@ -744,7 +737,7 @@ describe('reactRouterProvider', () => { ).toBeInTheDocument(); }); - await user.click( + fireEvent.click( screen.getByText('Go to same page with search param') ); @@ -771,13 +764,12 @@ describe('reactRouterProvider', () => { }); it('should match show routes', async () => { - const user = userEvent.setup(); render(); await waitFor(() => { expect(screen.getByText('Post #1')).toBeInTheDocument(); }); - await user.click(screen.getByText('Post #1')); + fireEvent.click(screen.getByText('Post #1')); await waitFor( () => { @@ -790,19 +782,18 @@ describe('reactRouterProvider', () => { }); it('should navigate between resources', async () => { - const user = userEvent.setup(); render(); await waitFor(() => { expect(screen.getByText('Posts')).toBeInTheDocument(); }); - await user.click(screen.getByText('Go to Comments')); + fireEvent.click(screen.getByText('Go to Comments')); await waitFor(() => { expect(screen.getByText('Comments')).toBeInTheDocument(); }); - await user.click(screen.getByText('Go to Posts')); + fireEvent.click(screen.getByText('Go to Posts')); await waitFor(() => { expect(screen.getByText('Posts')).toBeInTheDocument(); @@ -812,7 +803,6 @@ describe('reactRouterProvider', () => { describe('custom routes', () => { it('should render custom routes with layout', async () => { - const user = userEvent.setup(); render(); await waitFor(() => { expect( @@ -820,7 +810,7 @@ describe('reactRouterProvider', () => { ).toBeInTheDocument(); }); - await user.click(screen.getByText('Go to Custom Page')); + fireEvent.click(screen.getByText('Go to Custom Page')); await waitFor(() => { expect(screen.getByText('Custom Page')).toBeInTheDocument(); @@ -833,7 +823,6 @@ describe('reactRouterProvider', () => { }); it('should render custom routes without layout', async () => { - const user = userEvent.setup(); render(); await waitFor(() => { expect( @@ -841,7 +830,7 @@ describe('reactRouterProvider', () => { ).toBeInTheDocument(); }); - await user.click( + fireEvent.click( screen.getByText('Go to Custom Page (No Layout)') ); @@ -858,7 +847,6 @@ describe('reactRouterProvider', () => { }); it('should navigate from custom route back to resource', async () => { - const user = userEvent.setup(); render(); await waitFor(() => { expect( @@ -866,13 +854,13 @@ describe('reactRouterProvider', () => { ).toBeInTheDocument(); }); - await user.click(screen.getByText('Go to Custom Page')); + fireEvent.click(screen.getByText('Go to Custom Page')); await waitFor(() => { expect(screen.getByText('Custom Page')).toBeInTheDocument(); }); - await user.click(screen.getByText('Go to Posts')); + fireEvent.click(screen.getByText('Go to Posts')); await waitFor(() => { expect(screen.getByText('Posts')).toBeInTheDocument(); @@ -893,13 +881,12 @@ describe('reactRouterProvider', () => { }); it('should return id param on show page', async () => { - const user = userEvent.setup(); render(); await waitFor(() => { expect(screen.getByText('Post #1')).toBeInTheDocument(); }); - await user.click(screen.getByText('Post #1')); + fireEvent.click(screen.getByText('Post #1')); await waitFor( () => { @@ -916,13 +903,12 @@ describe('reactRouterProvider', () => { }); it('should return different id param for different records', async () => { - const user = userEvent.setup(); render(); await waitFor(() => { expect(screen.getByText('Post #2')).toBeInTheDocument(); }); - await user.click(screen.getByText('Post #2')); + fireEvent.click(screen.getByText('Post #2')); await waitFor( () => { @@ -966,13 +952,12 @@ describe('reactRouterProvider', () => { }); it('should not match exact route on nested path', async () => { - const user = userEvent.setup(); render(); await waitFor(() => { expect(screen.getByText('Post #1')).toBeInTheDocument(); }); - await user.click(screen.getByText('Post #1')); + fireEvent.click(screen.getByText('Post #1')); await waitFor( () => { @@ -992,13 +977,12 @@ describe('reactRouterProvider', () => { }); it('should update match when navigating to different resource', async () => { - const user = userEvent.setup(); render(); await waitFor(() => { expect(screen.getByText('Posts List')).toBeInTheDocument(); }); - await user.click(screen.getByText('Comments')); + fireEvent.click(screen.getByText('Comments')); await waitFor(() => { expect(screen.getByText('Comments List')).toBeInTheDocument(); @@ -1027,7 +1011,7 @@ describe('reactRouterProvider', () => { render(); const [title] = await screen.findAllByRole('textbox'); await user.type(title, 'A new title'); - await user.click(screen.getByText('Go to Comments')); + fireEvent.click(screen.getByText('Go to Comments')); await waitFor(() => { expect(confirmCalled).toBe(true); }); @@ -1052,7 +1036,7 @@ describe('reactRouterProvider', () => { render(); const [title] = await screen.findAllByRole('textbox'); await user.type(title, 'A new title'); - await user.click(screen.getByText('Go to Comments')); + fireEvent.click(screen.getByText('Go to Comments')); await waitFor(() => { expect( screen.getByText('You navigated away from the form.') @@ -1071,10 +1055,9 @@ describe('reactRouterProvider', () => { return true; }; try { - const user = userEvent.setup(); render(); await screen.findByText('Form with Unsaved Changes Warning'); - await user.click(screen.getByText('Go to Comments')); + fireEvent.click(screen.getByText('Go to Comments')); await waitFor(() => { expect( screen.getByText('You navigated away from the form.') @@ -1089,13 +1072,12 @@ describe('reactRouterProvider', () => { describe('Navigate', () => { it('should redirect to target route', async () => { - const user = userEvent.setup(); render(); await waitFor(() => { expect(screen.getByTestId('posts-page')).toBeInTheDocument(); }); - await user.click( + fireEvent.click( screen.getByText('Go to Redirect Page (auto-redirects here)') ); @@ -1106,13 +1088,12 @@ describe('reactRouterProvider', () => { }); it('should preserve search params on redirect', async () => { - const user = userEvent.setup(); render(); await waitFor(() => { expect(screen.getByTestId('posts-page')).toBeInTheDocument(); }); - await user.click(screen.getByText('Go to redirect with params')); + fireEvent.click(screen.getByText('Go to redirect with params')); // Should immediately redirect back to posts with search params await waitFor(() => { @@ -1128,13 +1109,12 @@ describe('reactRouterProvider', () => { }); it('should redirect conditionally when state changes', async () => { - const user = userEvent.setup(); render(); await waitFor(() => { expect(screen.getByTestId('posts-page')).toBeInTheDocument(); }); - await user.click(screen.getByText('Go to Conditional Redirect')); + fireEvent.click(screen.getByText('Go to Conditional Redirect')); await waitFor(() => { expect( @@ -1142,7 +1122,7 @@ describe('reactRouterProvider', () => { ).toBeInTheDocument(); }); - await user.click(screen.getByTestId('trigger-redirect')); + fireEvent.click(screen.getByTestId('trigger-redirect')); await waitFor(() => { expect(screen.getByTestId('posts-page')).toBeInTheDocument(); @@ -1150,7 +1130,6 @@ describe('reactRouterProvider', () => { }); it('should support location object with only search (no pathname)', async () => { - const user = userEvent.setup(); render(); await waitFor(() => { expect(screen.getByTestId('posts-page')).toBeInTheDocument(); @@ -1159,7 +1138,7 @@ describe('reactRouterProvider', () => { // Navigate to the Navigate search-only test page // This page uses // which should stay on the same pathname but add search params - await user.click( + fireEvent.click( screen.getByText('Go to Navigate search-only test') ); @@ -1206,13 +1185,12 @@ describe('reactRouterProvider', () => { }); it('should update pathname on navigation', async () => { - const user = userEvent.setup(); render(); await waitFor(() => { expect(screen.getByText('Go to Post Show')).toBeInTheDocument(); }); - await user.click(screen.getByText('Go to Post Show')); + fireEvent.click(screen.getByText('Go to Post Show')); await waitFor(() => { expect(screen.getByText('Post Show')).toBeInTheDocument(); @@ -1224,7 +1202,6 @@ describe('reactRouterProvider', () => { }); it('should include state when navigated with state', async () => { - const user = userEvent.setup(); render(); await waitFor(() => { expect( @@ -1232,7 +1209,7 @@ describe('reactRouterProvider', () => { ).toBeInTheDocument(); }); - await user.click(screen.getByText('Go to Post Show (with state)')); + fireEvent.click(screen.getByText('Go to Post Show (with state)')); await waitFor(() => { expect(screen.getByText('Post Show')).toBeInTheDocument(); @@ -1279,11 +1256,10 @@ describe('reactRouterProvider', () => { describe('Nested Routes with Outlet', () => { it('should render the default tab content', async () => { - const user = userEvent.setup(); render(); await screen.findByText('Post #1'); - await user.click(screen.getByText('Post #1')); + fireEvent.click(screen.getByText('Post #1')); await screen.findByText('Tabbed Layout (like TabbedShowLayout)'); // Should render the first tab (content) by default @@ -1296,15 +1272,14 @@ describe('reactRouterProvider', () => { }); it('should navigate between tabs using Outlet', async () => { - const user = userEvent.setup(); render(); await screen.findByText('Post #1'); - await user.click(screen.getByText('Post #1')); + fireEvent.click(screen.getByText('Post #1')); await screen.findByTestId('content-tab'); // Click on the second tab (Metadata) - await user.click(screen.getByText('Metadata Tab')); + fireEvent.click(screen.getByText('Metadata Tab')); await screen.findByTestId('metadata-tab'); expect( @@ -1314,13 +1289,12 @@ describe('reactRouterProvider', () => { }); it('should navigate back to first tab', async () => { - const user = userEvent.setup(); render(); await waitFor(() => { expect(screen.getByText('Post #1')).toBeInTheDocument(); }); - await user.click(screen.getByText('Post #1')); + fireEvent.click(screen.getByText('Post #1')); await waitFor( () => { @@ -1332,14 +1306,14 @@ describe('reactRouterProvider', () => { ); // Go to second tab - await user.click(screen.getByText('Metadata Tab')); + fireEvent.click(screen.getByText('Metadata Tab')); await waitFor(() => { expect(screen.getByTestId('metadata-tab')).toBeInTheDocument(); }); // Go back to first tab - await user.click(screen.getByText('Content Tab')); + fireEvent.click(screen.getByText('Content Tab')); await waitFor(() => { expect(screen.getByTestId('content-tab')).toBeInTheDocument(); @@ -1353,11 +1327,10 @@ describe('reactRouterProvider', () => { describe('Nested Resources (Route children of Resource)', () => { it('should navigate to child routes defined inside Resource', async () => { - const user = userEvent.setup(); render(); await screen.findByText('Post #1'); - await user.click(screen.getByText('Post #1')); + fireEvent.click(screen.getByText('Post #1')); await waitFor( () => { @@ -1372,7 +1345,6 @@ describe('reactRouterProvider', () => { describe('Query Parameters', () => { it('should update URL with query parameters when sorting', async () => { - const user = userEvent.setup(); render(); await screen.findByText('Posts with Query Parameters'); @@ -1382,7 +1354,7 @@ describe('reactRouterProvider', () => { ); // Click sort by title - await user.click(screen.getByTestId('sort-title')); + fireEvent.click(screen.getByTestId('sort-title')); await waitFor(() => { expect( @@ -1396,12 +1368,11 @@ describe('reactRouterProvider', () => { }); it('should update URL with query parameters when changing page', async () => { - const user = userEvent.setup(); render(); await screen.findByText('Posts with Query Parameters'); // Click page 2 - await user.click(screen.getByTestId('page-2')); + fireEvent.click(screen.getByTestId('page-2')); await waitFor(() => { expect( @@ -1415,12 +1386,11 @@ describe('reactRouterProvider', () => { }); it('should preserve query parameters across multiple updates', async () => { - const user = userEvent.setup(); render(); await screen.findByText('Posts with Query Parameters'); // Set sort - await user.click(screen.getByTestId('sort-title')); + fireEvent.click(screen.getByTestId('sort-title')); await waitFor(() => { expect( @@ -1429,7 +1399,7 @@ describe('reactRouterProvider', () => { }); // Set page - await user.click(screen.getByTestId('page-3')); + fireEvent.click(screen.getByTestId('page-3')); await waitFor(() => { const search = @@ -1454,7 +1424,6 @@ describe('reactRouterProvider', () => { it('should navigate between child routes within pathless layout', async () => { window.location.hash = '#/posts'; - const user = userEvent.setup(); render(); @@ -1463,7 +1432,7 @@ describe('reactRouterProvider', () => { expect(screen.getByTestId('posts-page')).toBeInTheDocument(); }); - await user.click(screen.getByText('Comments')); + fireEvent.click(screen.getByText('Comments')); await waitFor(() => { expect(screen.getByText('Layout Wrapper')).toBeInTheDocument(); @@ -1473,7 +1442,6 @@ describe('reactRouterProvider', () => { it('should match the most specific layout route within pathless layout routes', async () => { window.location.hash = '#/posts'; - const user = userEvent.setup(); render(); @@ -1481,13 +1449,13 @@ describe('reactRouterProvider', () => { expect(screen.getByTestId('posts-page')).toBeInTheDocument(); }); - await user.click(screen.getByText('User')); + fireEvent.click(screen.getByText('User')); await waitFor(() => { expect(screen.getByTestId('users-page')).toBeInTheDocument(); }); - await user.click(screen.getByText('Block a user')); + fireEvent.click(screen.getByText('Block a user')); await waitFor(() => { expect( @@ -1499,7 +1467,6 @@ describe('reactRouterProvider', () => { it('should match the empty path route as most specific within pathless layout routes', async () => { window.location.hash = '#/posts'; - const user = userEvent.setup(); render(); @@ -1507,7 +1474,7 @@ describe('reactRouterProvider', () => { expect(screen.getByTestId('posts-page')).toBeInTheDocument(); }); - await user.click(screen.getByText('Home (path="")')); + fireEvent.click(screen.getByText('Home (path="")')); await waitFor(() => { expect(screen.getByTestId('home-page')).toBeInTheDocument(); @@ -1516,7 +1483,6 @@ describe('reactRouterProvider', () => { it('should match the index route as most specific within pathless layout routes', async () => { window.location.hash = '#/posts'; - const user = userEvent.setup(); render(); @@ -1524,7 +1490,7 @@ describe('reactRouterProvider', () => { expect(screen.getByTestId('posts-page')).toBeInTheDocument(); }); - await user.click(screen.getByText('Home (index)')); + fireEvent.click(screen.getByText('Home (index)')); await waitFor(() => { expect(screen.getByTestId('home-page')).toBeInTheDocument(); @@ -1533,20 +1499,19 @@ describe('reactRouterProvider', () => { describe('Resource Children (Route as children of Resource)', () => { it('should navigate to child routes without matching parent edit route', async () => { - const user = userEvent.setup(); render(); // Wait for posts list to load await screen.findByText('Post #1'); // Click on a post to go to edit page - await user.click(screen.getByText('Post #1')); + fireEvent.click(screen.getByText('Post #1')); // Wait for edit page await screen.findByText('Post Details'); // Click to view comments (child route) - await user.click(screen.getByText('View Comments')); + fireEvent.click(screen.getByText('View Comments')); // Should navigate to comments page, not stay on edit await waitFor(() => { From 87fed4c4be3d54f324b9e96d0b65f89d6dc8ae48 Mon Sep 17 00:00:00 2001 From: Shaoyu Meng Date: Thu, 25 Jun 2026 18:29:56 -0700 Subject: [PATCH 40/56] test: Align story import order with the tanstack spec Restore the tanstack import ordering (PathlessLayoutRoutes before NestedResourcesPrecedence) in both router adapter specs. No behavior change; 107/107 in both adapters. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src/reactRouterNextProvider.spec.tsx | 2 +- .../ra-router-react-router/src/reactRouterProvider.spec.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/ra-router-react-router-next/src/reactRouterNextProvider.spec.tsx b/packages/ra-router-react-router-next/src/reactRouterNextProvider.spec.tsx index 939021cf789..ca31726845a 100644 --- a/packages/ra-router-react-router-next/src/reactRouterNextProvider.spec.tsx +++ b/packages/ra-router-react-router-next/src/reactRouterNextProvider.spec.tsx @@ -23,8 +23,8 @@ import { NestedRoutesWithOutlet, NestedResources, QueryParameters, - NestedResourcesPrecedence, PathlessLayoutRoutes, + NestedResourcesPrecedence, PathlessLayoutRoutesPriority, PathlessLayoutRoutesWithEmptyRoute, PathlessLayoutRoutesWithIndexRoute, diff --git a/packages/ra-router-react-router/src/reactRouterProvider.spec.tsx b/packages/ra-router-react-router/src/reactRouterProvider.spec.tsx index d549f612743..65741a9e48b 100644 --- a/packages/ra-router-react-router/src/reactRouterProvider.spec.tsx +++ b/packages/ra-router-react-router/src/reactRouterProvider.spec.tsx @@ -23,8 +23,8 @@ import { NestedRoutesWithOutlet, NestedResources, QueryParameters, - NestedResourcesPrecedence, PathlessLayoutRoutes, + NestedResourcesPrecedence, PathlessLayoutRoutesPriority, PathlessLayoutRoutesWithEmptyRoute, PathlessLayoutRoutesWithIndexRoute, From 5aad20a7b398578e79ecc6e41a15d9cb71ba9766 Mon Sep 17 00:00:00 2001 From: Shaoyu Meng Date: Thu, 25 Jun 2026 18:33:03 -0700 Subject: [PATCH 41/56] test: Restore user.click in the embedded navigate-back test (tanstack parity) The earlier blanket fireEvent.click conversion wrongly flipped the one test that tanstack drives with user.click ("navigate back to parent app"). Match tanstack per-test: user.click + userEvent.setup there, fireEvent.click everywhere else. 107/107 in both adapters. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src/reactRouterNextProvider.spec.tsx | 3 ++- .../ra-router-react-router/src/reactRouterProvider.spec.tsx | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/ra-router-react-router-next/src/reactRouterNextProvider.spec.tsx b/packages/ra-router-react-router-next/src/reactRouterNextProvider.spec.tsx index ca31726845a..b31f0517e2b 100644 --- a/packages/ra-router-react-router-next/src/reactRouterNextProvider.spec.tsx +++ b/packages/ra-router-react-router-next/src/reactRouterNextProvider.spec.tsx @@ -579,12 +579,13 @@ describe('reactRouterNextProvider', () => { }); it('should navigate back to parent app', async () => { + const user = userEvent.setup(); render(); await waitFor(() => { expect(screen.getByText('Admin')).toBeInTheDocument(); }); - fireEvent.click(screen.getByText('Admin')); + await user.click(screen.getByText('Admin')); await waitFor( () => { diff --git a/packages/ra-router-react-router/src/reactRouterProvider.spec.tsx b/packages/ra-router-react-router/src/reactRouterProvider.spec.tsx index 65741a9e48b..4937f97ac87 100644 --- a/packages/ra-router-react-router/src/reactRouterProvider.spec.tsx +++ b/packages/ra-router-react-router/src/reactRouterProvider.spec.tsx @@ -579,12 +579,13 @@ describe('reactRouterProvider', () => { }); it('should navigate back to parent app', async () => { + const user = userEvent.setup(); render(); await waitFor(() => { expect(screen.getByText('Admin')).toBeInTheDocument(); }); - fireEvent.click(screen.getByText('Admin')); + await user.click(screen.getByText('Admin')); await waitFor( () => { From 341bcb4d1998a785f03e8d787c30bc5409c45eab Mon Sep 17 00:00:00 2001 From: Shaoyu Meng Date: Thu, 25 Jun 2026 18:38:05 -0700 Subject: [PATCH 42/56] test: Restore tanstack's data-load comment in nested-routes test Add the "// Wait for data to load before clicking" comment that tanstack has between the two findByText calls in the navigate-within-nested-routes test, in both router adapter specs. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src/reactRouterNextProvider.spec.tsx | 2 ++ .../ra-router-react-router/src/reactRouterProvider.spec.tsx | 2 ++ 2 files changed, 4 insertions(+) diff --git a/packages/ra-router-react-router-next/src/reactRouterNextProvider.spec.tsx b/packages/ra-router-react-router-next/src/reactRouterNextProvider.spec.tsx index b31f0517e2b..9325f25b384 100644 --- a/packages/ra-router-react-router-next/src/reactRouterNextProvider.spec.tsx +++ b/packages/ra-router-react-router-next/src/reactRouterNextProvider.spec.tsx @@ -651,6 +651,8 @@ describe('reactRouterNextProvider', () => { 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')); diff --git a/packages/ra-router-react-router/src/reactRouterProvider.spec.tsx b/packages/ra-router-react-router/src/reactRouterProvider.spec.tsx index 4937f97ac87..136aee6fc33 100644 --- a/packages/ra-router-react-router/src/reactRouterProvider.spec.tsx +++ b/packages/ra-router-react-router/src/reactRouterProvider.spec.tsx @@ -651,6 +651,8 @@ describe('reactRouterProvider', () => { 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')); From f714be7fbe65cdcb1834aa3daf874be3ffb2c65c Mon Sep 17 00:00:00 2001 From: Shaoyu Meng Date: Thu, 25 Jun 2026 18:48:12 -0700 Subject: [PATCH 43/56] test: Tighten matchPath/useCanBlock parity with the tanstack spec Remove the extra explanatory comment atop the matchPath block and reword the useCanBlock test to "should return true for React Router", matching the tanstack spec wording. 107/107 in both adapters. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src/reactRouterNextProvider.spec.tsx | 9 +-------- .../src/reactRouterProvider.spec.tsx | 9 +-------- 2 files changed, 2 insertions(+), 16 deletions(-) diff --git a/packages/ra-router-react-router-next/src/reactRouterNextProvider.spec.tsx b/packages/ra-router-react-router-next/src/reactRouterNextProvider.spec.tsx index 9325f25b384..91ddc8466e2 100644 --- a/packages/ra-router-react-router-next/src/reactRouterNextProvider.spec.tsx +++ b/packages/ra-router-react-router-next/src/reactRouterNextProvider.spec.tsx @@ -45,13 +45,6 @@ describe('reactRouterNextProvider', () => { }); describe('matchPath', () => { - // matchPath here is react-router's own implementation, so its results - // differ from the hand-rolled tanstack matcher in a few documented ways - // (splat values are not prefixed with "/", params are not fully decoded, - // empty and collapsed-slash paths do not match, trailing slashes are - // preserved in `pathname`). The assertions below capture react-router's - // actual behavior and use `toMatchObject` because react-router also - // returns a `pattern` field. describe('catch-all patterns', () => { it('should match "*" against any path', () => { expect(matchPath('*', '/anything')).toMatchObject({ @@ -1243,7 +1236,7 @@ describe('reactRouterNextProvider', () => { }); describe('useCanBlock', () => { - it('should return true for a data router', async () => { + it('should return true for React Router', async () => { render(); await waitFor(() => { expect( diff --git a/packages/ra-router-react-router/src/reactRouterProvider.spec.tsx b/packages/ra-router-react-router/src/reactRouterProvider.spec.tsx index 136aee6fc33..3d8ec3ddf66 100644 --- a/packages/ra-router-react-router/src/reactRouterProvider.spec.tsx +++ b/packages/ra-router-react-router/src/reactRouterProvider.spec.tsx @@ -45,13 +45,6 @@ describe('reactRouterProvider', () => { }); describe('matchPath', () => { - // matchPath here is react-router's own implementation, so its results - // differ from the hand-rolled tanstack matcher in a few documented ways - // (splat values are not prefixed with "/", params are not fully decoded, - // empty and collapsed-slash paths do not match, trailing slashes are - // preserved in `pathname`). The assertions below capture react-router's - // actual behavior and use `toMatchObject` because react-router also - // returns a `pattern` field. describe('catch-all patterns', () => { it('should match "*" against any path', () => { expect(matchPath('*', '/anything')).toMatchObject({ @@ -1243,7 +1236,7 @@ describe('reactRouterProvider', () => { }); describe('useCanBlock', () => { - it('should return true for a data router', async () => { + it('should return true for React Router', async () => { render(); await waitFor(() => { expect( From 3522062e12782a14623691d6b8d99e66ac42ee58 Mon Sep 17 00:00:00 2001 From: Shaoyu Meng Date: Thu, 25 Jun 2026 18:52:45 -0700 Subject: [PATCH 44/56] test: Clarify splat URL-decoding test description Rename to "should decode only path separator in URL-encoded splat values" in both router adapter specs. 107/107. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src/reactRouterNextProvider.spec.tsx | 2 +- .../ra-router-react-router/src/reactRouterProvider.spec.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/ra-router-react-router-next/src/reactRouterNextProvider.spec.tsx b/packages/ra-router-react-router-next/src/reactRouterNextProvider.spec.tsx index 91ddc8466e2..37c00531466 100644 --- a/packages/ra-router-react-router-next/src/reactRouterNextProvider.spec.tsx +++ b/packages/ra-router-react-router-next/src/reactRouterNextProvider.spec.tsx @@ -266,7 +266,7 @@ describe('reactRouterNextProvider', () => { }); }); - it('should decode the path separator in splat values', () => { + it('should decode only path separator in URL-encoded splat values', () => { expect( matchPath('/files/*', '/files/path%2Fto%2Ffile%20name.txt') ).toMatchObject({ diff --git a/packages/ra-router-react-router/src/reactRouterProvider.spec.tsx b/packages/ra-router-react-router/src/reactRouterProvider.spec.tsx index 3d8ec3ddf66..6553b9dd7e5 100644 --- a/packages/ra-router-react-router/src/reactRouterProvider.spec.tsx +++ b/packages/ra-router-react-router/src/reactRouterProvider.spec.tsx @@ -266,7 +266,7 @@ describe('reactRouterProvider', () => { }); }); - it('should decode the path separator in splat values', () => { + it('should decode only path separator in URL-encoded splat values', () => { expect( matchPath('/files/*', '/files/path%2Fto%2Ffile%20name.txt') ).toMatchObject({ From 1c5b74eb49b039073da9e161f06dff1cf47b9079 Mon Sep 17 00:00:00 2001 From: Shaoyu Meng Date: Thu, 25 Jun 2026 18:56:29 -0700 Subject: [PATCH 45/56] test: Restore tanstack comment and reword URL-encoded params test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rename the test to "should decode only path separator in URL-encoded params", restore tanstack's "// UTF-8 characters: 衣類/衣類 encoded" comment, and drop the react-router-specific note. 107/107 in both adapters. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src/reactRouterNextProvider.spec.tsx | 4 ++-- .../ra-router-react-router/src/reactRouterProvider.spec.tsx | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/ra-router-react-router-next/src/reactRouterNextProvider.spec.tsx b/packages/ra-router-react-router-next/src/reactRouterNextProvider.spec.tsx index 37c00531466..3c7a94f1bce 100644 --- a/packages/ra-router-react-router-next/src/reactRouterNextProvider.spec.tsx +++ b/packages/ra-router-react-router-next/src/reactRouterNextProvider.spec.tsx @@ -206,8 +206,8 @@ describe('reactRouterNextProvider', () => { }); }); - it('should not fully decode URL-encoded params (only the path separator)', () => { - // react-router decodes %2F to "/" but leaves the rest encoded. + it('should decode only path separator in URL-encoded params', () => { + // UTF-8 characters: 衣類/衣類 encoded expect( matchPath( '/comments/:id', diff --git a/packages/ra-router-react-router/src/reactRouterProvider.spec.tsx b/packages/ra-router-react-router/src/reactRouterProvider.spec.tsx index 6553b9dd7e5..d948ba1000a 100644 --- a/packages/ra-router-react-router/src/reactRouterProvider.spec.tsx +++ b/packages/ra-router-react-router/src/reactRouterProvider.spec.tsx @@ -206,8 +206,8 @@ describe('reactRouterProvider', () => { }); }); - it('should not fully decode URL-encoded params (only the path separator)', () => { - // react-router decodes %2F to "/" but leaves the rest encoded. + it('should decode only path separator in URL-encoded params', () => { + // UTF-8 characters: 衣類/衣類 encoded expect( matchPath( '/comments/:id', From deee383362298c0c4c189f4b57efaa4ebceaa22e Mon Sep 17 00:00:00 2001 From: Shaoyu Meng Date: Thu, 25 Jun 2026 19:00:07 -0700 Subject: [PATCH 46/56] test: Align useWarnWhenUnsavedChanges test descriptions with tanstack Rename the three tests to match the tanstack useBlocker wording: "should block navigation when form is dirty", "should allow navigation when clicking proceed", and "should not block navigation when form is not dirty". 107/107 in both adapters. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src/reactRouterNextProvider.spec.tsx | 6 +++--- .../ra-router-react-router/src/reactRouterProvider.spec.tsx | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/ra-router-react-router-next/src/reactRouterNextProvider.spec.tsx b/packages/ra-router-react-router-next/src/reactRouterNextProvider.spec.tsx index 3c7a94f1bce..cdc98442c9b 100644 --- a/packages/ra-router-react-router-next/src/reactRouterNextProvider.spec.tsx +++ b/packages/ra-router-react-router-next/src/reactRouterNextProvider.spec.tsx @@ -994,7 +994,7 @@ describe('reactRouterNextProvider', () => { }); describe('useWarnWhenUnsavedChanges', () => { - it('should confirm before navigating away from a dirty form', async () => { + it('should block navigation when form is dirty', async () => { const originalConfirm = window.confirm; let confirmCalled = false; // Decline the confirmation so navigation stays blocked. @@ -1023,7 +1023,7 @@ describe('reactRouterNextProvider', () => { } }); - it('should navigate away from a dirty form once confirmed', async () => { + it('should allow navigation when clicking proceed', async () => { const originalConfirm = window.confirm; // Accept the confirmation so navigation proceeds. window.confirm = () => true; @@ -1043,7 +1043,7 @@ describe('reactRouterNextProvider', () => { } }); - it('should not confirm when the form is not dirty', async () => { + it('should not block navigation when form is not dirty', async () => { const originalConfirm = window.confirm; let confirmCalled = false; window.confirm = () => { diff --git a/packages/ra-router-react-router/src/reactRouterProvider.spec.tsx b/packages/ra-router-react-router/src/reactRouterProvider.spec.tsx index d948ba1000a..76c06ca1eca 100644 --- a/packages/ra-router-react-router/src/reactRouterProvider.spec.tsx +++ b/packages/ra-router-react-router/src/reactRouterProvider.spec.tsx @@ -994,7 +994,7 @@ describe('reactRouterProvider', () => { }); describe('useWarnWhenUnsavedChanges', () => { - it('should confirm before navigating away from a dirty form', async () => { + it('should block navigation when form is dirty', async () => { const originalConfirm = window.confirm; let confirmCalled = false; // Decline the confirmation so navigation stays blocked. @@ -1023,7 +1023,7 @@ describe('reactRouterProvider', () => { } }); - it('should navigate away from a dirty form once confirmed', async () => { + it('should allow navigation when clicking proceed', async () => { const originalConfirm = window.confirm; // Accept the confirmation so navigation proceeds. window.confirm = () => true; @@ -1043,7 +1043,7 @@ describe('reactRouterProvider', () => { } }); - it('should not confirm when the form is not dirty', async () => { + it('should not block navigation when form is not dirty', async () => { const originalConfirm = window.confirm; let confirmCalled = false; window.confirm = () => { From 3a7206bf5167e55afacc511d1074398f9c0c418f Mon Sep 17 00:00:00 2001 From: Shaoyu Meng Date: Thu, 25 Jun 2026 19:07:27 -0700 Subject: [PATCH 47/56] test: Split block vs cancel in useWarnWhenUnsavedChanges tests The "should block navigation when form is dirty" test was conflating the block (confirm fires) with the cancel outcome (declining keeps you on the form). Split into separate "should block navigation when form is dirty" and "should cancel navigation when clicking cancel" tests, ordered like tanstack (block, proceed, cancel, not-dirty). 108/108 in both adapters. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src/reactRouterNextProvider.spec.tsx | 33 ++++++++++++++----- .../src/reactRouterProvider.spec.tsx | 33 ++++++++++++++----- 2 files changed, 50 insertions(+), 16 deletions(-) diff --git a/packages/ra-router-react-router-next/src/reactRouterNextProvider.spec.tsx b/packages/ra-router-react-router-next/src/reactRouterNextProvider.spec.tsx index cdc98442c9b..d9a01f51619 100644 --- a/packages/ra-router-react-router-next/src/reactRouterNextProvider.spec.tsx +++ b/packages/ra-router-react-router-next/src/reactRouterNextProvider.spec.tsx @@ -997,7 +997,6 @@ describe('reactRouterNextProvider', () => { it('should block navigation when form is dirty', async () => { const originalConfirm = window.confirm; let confirmCalled = false; - // Decline the confirmation so navigation stays blocked. window.confirm = () => { confirmCalled = true; return false; @@ -1008,16 +1007,10 @@ describe('reactRouterNextProvider', () => { const [title] = await screen.findAllByRole('textbox'); await user.type(title, 'A new title'); fireEvent.click(screen.getByText('Go to Comments')); + // Leaving a dirty form triggers the confirmation prompt. await waitFor(() => { expect(confirmCalled).toBe(true); }); - // confirm returned false => navigation blocked, still on the form - expect( - screen.getByText('Form with Unsaved Changes Warning') - ).toBeInTheDocument(); - expect( - screen.getByDisplayValue('A new title') - ).toBeInTheDocument(); } finally { window.confirm = originalConfirm; } @@ -1043,6 +1036,30 @@ describe('reactRouterNextProvider', () => { } }); + it('should cancel navigation when clicking cancel', async () => { + const originalConfirm = window.confirm; + // Decline the confirmation so navigation is cancelled. + 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')); + // confirm returned false => navigation cancelled, still on the form + 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; diff --git a/packages/ra-router-react-router/src/reactRouterProvider.spec.tsx b/packages/ra-router-react-router/src/reactRouterProvider.spec.tsx index 76c06ca1eca..f24c53fa01d 100644 --- a/packages/ra-router-react-router/src/reactRouterProvider.spec.tsx +++ b/packages/ra-router-react-router/src/reactRouterProvider.spec.tsx @@ -997,7 +997,6 @@ describe('reactRouterProvider', () => { it('should block navigation when form is dirty', async () => { const originalConfirm = window.confirm; let confirmCalled = false; - // Decline the confirmation so navigation stays blocked. window.confirm = () => { confirmCalled = true; return false; @@ -1008,16 +1007,10 @@ describe('reactRouterProvider', () => { const [title] = await screen.findAllByRole('textbox'); await user.type(title, 'A new title'); fireEvent.click(screen.getByText('Go to Comments')); + // Leaving a dirty form triggers the confirmation prompt. await waitFor(() => { expect(confirmCalled).toBe(true); }); - // confirm returned false => navigation blocked, still on the form - expect( - screen.getByText('Form with Unsaved Changes Warning') - ).toBeInTheDocument(); - expect( - screen.getByDisplayValue('A new title') - ).toBeInTheDocument(); } finally { window.confirm = originalConfirm; } @@ -1043,6 +1036,30 @@ describe('reactRouterProvider', () => { } }); + it('should cancel navigation when clicking cancel', async () => { + const originalConfirm = window.confirm; + // Decline the confirmation so navigation is cancelled. + 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')); + // confirm returned false => navigation cancelled, still on the form + 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; From bf8103e92d506b0f9ef7384bd3b6c729b741d966 Mon Sep 17 00:00:00 2001 From: Shaoyu Meng Date: Thu, 25 Jun 2026 19:10:22 -0700 Subject: [PATCH 48/56] test: Drop inline comments from useWarnWhenUnsavedChanges tests Match the tanstack useBlocker tests, which have no inline comments in the test bodies. 108/108 in both adapters. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src/reactRouterNextProvider.spec.tsx | 4 ---- .../ra-router-react-router/src/reactRouterProvider.spec.tsx | 4 ---- 2 files changed, 8 deletions(-) diff --git a/packages/ra-router-react-router-next/src/reactRouterNextProvider.spec.tsx b/packages/ra-router-react-router-next/src/reactRouterNextProvider.spec.tsx index d9a01f51619..c1b7f9d0d42 100644 --- a/packages/ra-router-react-router-next/src/reactRouterNextProvider.spec.tsx +++ b/packages/ra-router-react-router-next/src/reactRouterNextProvider.spec.tsx @@ -1007,7 +1007,6 @@ describe('reactRouterNextProvider', () => { const [title] = await screen.findAllByRole('textbox'); await user.type(title, 'A new title'); fireEvent.click(screen.getByText('Go to Comments')); - // Leaving a dirty form triggers the confirmation prompt. await waitFor(() => { expect(confirmCalled).toBe(true); }); @@ -1018,7 +1017,6 @@ describe('reactRouterNextProvider', () => { it('should allow navigation when clicking proceed', async () => { const originalConfirm = window.confirm; - // Accept the confirmation so navigation proceeds. window.confirm = () => true; try { const user = userEvent.setup(); @@ -1038,7 +1036,6 @@ describe('reactRouterNextProvider', () => { it('should cancel navigation when clicking cancel', async () => { const originalConfirm = window.confirm; - // Decline the confirmation so navigation is cancelled. window.confirm = () => false; try { const user = userEvent.setup(); @@ -1046,7 +1043,6 @@ describe('reactRouterNextProvider', () => { const [title] = await screen.findAllByRole('textbox'); await user.type(title, 'A new title'); fireEvent.click(screen.getByText('Go to Comments')); - // confirm returned false => navigation cancelled, still on the form await waitFor(() => { expect( screen.getByText('Form with Unsaved Changes Warning') diff --git a/packages/ra-router-react-router/src/reactRouterProvider.spec.tsx b/packages/ra-router-react-router/src/reactRouterProvider.spec.tsx index f24c53fa01d..c7f059ab64c 100644 --- a/packages/ra-router-react-router/src/reactRouterProvider.spec.tsx +++ b/packages/ra-router-react-router/src/reactRouterProvider.spec.tsx @@ -1007,7 +1007,6 @@ describe('reactRouterProvider', () => { const [title] = await screen.findAllByRole('textbox'); await user.type(title, 'A new title'); fireEvent.click(screen.getByText('Go to Comments')); - // Leaving a dirty form triggers the confirmation prompt. await waitFor(() => { expect(confirmCalled).toBe(true); }); @@ -1018,7 +1017,6 @@ describe('reactRouterProvider', () => { it('should allow navigation when clicking proceed', async () => { const originalConfirm = window.confirm; - // Accept the confirmation so navigation proceeds. window.confirm = () => true; try { const user = userEvent.setup(); @@ -1038,7 +1036,6 @@ describe('reactRouterProvider', () => { it('should cancel navigation when clicking cancel', async () => { const originalConfirm = window.confirm; - // Decline the confirmation so navigation is cancelled. window.confirm = () => false; try { const user = userEvent.setup(); @@ -1046,7 +1043,6 @@ describe('reactRouterProvider', () => { const [title] = await screen.findAllByRole('textbox'); await user.type(title, 'A new title'); fireEvent.click(screen.getByText('Go to Comments')); - // confirm returned false => navigation cancelled, still on the form await waitFor(() => { expect( screen.getByText('Form with Unsaved Changes Warning') From 5595607897453b41b7755a4e508e44a30a217416 Mon Sep 17 00:00:00 2001 From: Shaoyu Meng Date: Thu, 25 Jun 2026 19:19:53 -0700 Subject: [PATCH 49/56] docs: Import Route components from react-router instead of react-router-dom Part of the React Router v8 migration: v8 merged react-router-dom into react-router. Move Route/Routes/RouterProvider/BrowserRouter doc imports to react-router, while keeping createBrowserRouter and Link on react-router-dom for v6 compatibility. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/Admin.md | 17 ++++++++++------- docs/Architecture.md | 2 +- docs/Authenticated.md | 2 +- docs/Authentication.md | 4 ++-- docs/Breadcrumb.md | 4 ++-- docs/CanAccess.md | 2 +- docs/ContainerLayout.md | 2 +- docs/CustomRoutes.md | 14 +++++++------- docs/DeletedRecordsList.md | 6 +++--- docs/List.md | 2 +- docs/Permissions.md | 2 +- docs/Resource.md | 4 ++-- docs/Routing.md | 11 +++++++---- docs/SecurityGuide.md | 2 +- docs/ShowDeleted.md | 2 +- docs/WithPermissions.md | 2 +- docs/useDefineAppLocation.md | 2 +- docs/usePermissions.md | 2 +- docs_headless/src/content/docs/Architecture.md | 2 +- docs_headless/src/content/docs/Authenticated.md | 2 +- .../src/content/docs/Authentication.md | 4 ++-- docs_headless/src/content/docs/CRUD.md | 2 +- docs_headless/src/content/docs/CanAccess.md | 2 +- docs_headless/src/content/docs/CoreAdmin.md | 17 ++++++++++------- docs_headless/src/content/docs/CustomRoutes.md | 12 ++++++------ .../content/docs/DeletedRecordRepresentation.md | 2 +- .../src/content/docs/DeletedRecordsListBase.md | 4 ++-- docs_headless/src/content/docs/Permissions.md | 2 +- docs_headless/src/content/docs/Resource.md | 4 ++-- docs_headless/src/content/docs/Routing.md | 11 +++++++---- docs_headless/src/content/docs/SecurityGuide.md | 2 +- .../src/content/docs/ShowDeletedBase.md | 2 +- .../src/content/docs/usePermissions.md | 2 +- 33 files changed, 82 insertions(+), 70 deletions(-) diff --git a/docs/Admin.md b/docs/Admin.md index 2e14aefbeb2..54307fbd0d6 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,7 @@ 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 { BrowserRouter, Routes, Route } from 'react-router'; import { StoreFront } from './StoreFront'; import { StoreAdmin } from './StoreAdmin'; @@ -1158,7 +1158,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 +1190,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 +1218,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 +1251,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/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 2fe76c64dca..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; @@ -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..704dcb4f897 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,7 @@ 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 { BrowserRouter, Routes, Route } from 'react-router'; import { StoreFront } from './StoreFront'; import { StoreAdmin } from './StoreAdmin'; @@ -1079,7 +1079,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 +1111,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 +1139,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 +1172,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 [ From 1fac4d3f6c77ea288f3a8d19442f3594a5ba42ce Mon Sep 17 00:00:00 2001 From: Shaoyu Meng Date: Thu, 25 Jun 2026 19:24:36 -0700 Subject: [PATCH 50/56] docs: Keep BrowserRouter on react-router-dom for v6 compatibility BrowserRouter is not exported from the react-router core package in v6 (it is DOM-only, like Link and createBrowserRouter). Revert the two BrowserRouter imports back to react-router-dom; Routes/Route stay on react-router. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/Admin.md | 3 ++- docs_headless/src/content/docs/CoreAdmin.md | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/docs/Admin.md b/docs/Admin.md index 54307fbd0d6..5f8c81651d1 100644 --- a/docs/Admin.md +++ b/docs/Admin.md @@ -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'; +import { Routes, Route } from 'react-router'; +import { BrowserRouter } from 'react-router-dom'; import { StoreFront } from './StoreFront'; import { StoreAdmin } from './StoreAdmin'; diff --git a/docs_headless/src/content/docs/CoreAdmin.md b/docs_headless/src/content/docs/CoreAdmin.md index 704dcb4f897..3a96bae6651 100644 --- a/docs_headless/src/content/docs/CoreAdmin.md +++ b/docs_headless/src/content/docs/CoreAdmin.md @@ -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'; +import { Routes, Route } from 'react-router'; +import { BrowserRouter } from 'react-router-dom'; import { StoreFront } from './StoreFront'; import { StoreAdmin } from './StoreAdmin'; From 95b0fe8f084549cb0de89798cfc775c7f2a087d0 Mon Sep 17 00:00:00 2001 From: Shaoyu Meng Date: Thu, 25 Jun 2026 19:35:42 -0700 Subject: [PATCH 51/56] fix: Scope experimental-vm-modules to the react-router-next test run MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `NODE_OPTIONS=--experimental-vm-modules` is process-global, so setting it on the shared `test-unit`/`test-unit-ci` scripts pushed the entire default (CommonJS) jest project into ESM mode. Jest then honored the `"type": "module"` field of transformed dependencies and refused to `require()` them, so every suite that transitively imports an ESM-only dep (e.g. react-hotkeys-hook, pulled in via the layout/button/form barrels) failed to load with "Must use import to load ES Module". This took out 93 suites across ra-ui-materialui, react-admin, ra-input-rich-text and ra-i18n-polyglot — the suites failed to run while their individual tests never executed. Keep the root config a single CommonJS project (no `projects` array, no flag) and run ra-router-react-router-next's ESM/React 19 tests as a separate jest invocation against its own jest.config.cjs, appended to the test scripts with the flag set only for that run. Co-Authored-By: Claude Opus 4.8 (1M context) --- jest.config.js | 60 ++++++++++++++++++++++++-------------------------- package.json | 4 ++-- 2 files changed, 31 insertions(+), 33 deletions(-) diff --git a/jest.config.js b/jest.config.js index a755fa473fa..886619755a2 100644 --- a/jest.config.js +++ b/jest.config.js @@ -14,37 +14,35 @@ const moduleNameMapper = packages.reduce((mapper, dirName) => { }, {}); module.exports = { - projects: [ - { - globalSetup: './test-global-setup.js', - setupFilesAfterEnv: ['./test-setup.js'], - testEnvironment: 'jsdom', - testPathIgnorePatterns: [ - '/node_modules/', - '/lib/', - '/esm/', - '/examples/simple/', - '/packages/create-react-admin/templates', - '/packages/ra-router-react-router-next/', - ], - transformIgnorePatterns: [ - '[/\\\\]node_modules[/\\\\](?!(@hookform|react-hotkeys-hook|@faker-js/faker)/).+\\.(js|jsx|mjs|ts|tsx)$', - ], - transform: { - // '^.+\\.[tj]sx?$' to process js/ts with `ts-jest` - '^.+\\.[tj]sx?$': [ - 'ts-jest', - { - isolatedModules: true, - useESM: true, - }, - ], - }, - moduleNameMapper, - }, - // ra-router-react-router-next can't be compiled into cjs. - // Running its tests requires `NODE_OPTIONS=--experimental-vm-modules`. - './packages/ra-router-react-router-next/jest.config.cjs', + globalSetup: './test-global-setup.js', + setupFilesAfterEnv: ['./test-setup.js'], + testEnvironment: 'jsdom', + testPathIgnorePatterns: [ + '/node_modules/', + '/lib/', + '/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)$', ], + transform: { + // '^.+\\.[tj]sx?$' to process js/ts with `ts-jest` + '^.+\\.[tj]sx?$': [ + 'ts-jest', + { + isolatedModules: true, + useESM: true, + }, + ], + }, + moduleNameMapper, testTimeout: 60000, }; diff --git a/package.json b/package.json index 8b76207d0dc..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 NODE_OPTIONS=--experimental-vm-modules 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 NODE_OPTIONS=--experimental-vm-modules 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", From 0cdb7101f690cb569d98f95d96d4caab54b7e941 Mon Sep 17 00:00:00 2001 From: Shaoyu Meng Date: Thu, 25 Jun 2026 19:38:26 -0700 Subject: [PATCH 52/56] chore: Declare react-router as a dependency of ra-router-react-router-next The package imports react-router at runtime, so it belongs in `dependencies` (matching the sibling ra-router-react-router), not only in dev/peer deps. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/ra-router-react-router-next/package.json | 3 +++ 1 file changed, 3 insertions(+) diff --git a/packages/ra-router-react-router-next/package.json b/packages/ra-router-react-router-next/package.json index e943d54d1b5..b3f7f103a44 100644 --- a/packages/ra-router-react-router-next/package.json +++ b/packages/ra-router-react-router-next/package.json @@ -24,6 +24,9 @@ "scripts": { "build": "zshy --silent" }, + "dependencies": { + "react-router": "^8.0.0" + }, "devDependencies": { "ra-core": "^5.14.7", "react": "^19.2.7", From 6db8be07a6c8da5e06934fdb0efdf05e5e733368 Mon Sep 17 00:00:00 2001 From: Shaoyu Meng Date: Thu, 25 Jun 2026 19:40:25 -0700 Subject: [PATCH 53/56] chore: Drop ra-core from ra-router-react-router-next dev/peer deps ra-core is provided by the react-admin meta-package at runtime; the sibling ra-router-react-router does not declare it either. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/ra-router-react-router-next/package.json | 2 -- 1 file changed, 2 deletions(-) diff --git a/packages/ra-router-react-router-next/package.json b/packages/ra-router-react-router-next/package.json index b3f7f103a44..9707dbeb667 100644 --- a/packages/ra-router-react-router-next/package.json +++ b/packages/ra-router-react-router-next/package.json @@ -28,7 +28,6 @@ "react-router": "^8.0.0" }, "devDependencies": { - "ra-core": "^5.14.7", "react": "^19.2.7", "react-dom": "^19.2.7", "react-router": "^8.0.0", @@ -36,7 +35,6 @@ "zshy": "^0.5.0" }, "peerDependencies": { - "ra-core": "^5.0.0", "react": "^19.2.7", "react-dom": "^19.2.7", "react-router": "^8.0.0" From 8d69dd720365c7f36ad24dbfb5f92657934e2a3c Mon Sep 17 00:00:00 2001 From: Shaoyu Meng Date: Thu, 25 Jun 2026 19:44:28 -0700 Subject: [PATCH 54/56] chore: Fix router package peer dependencies Restore ra-core as a peerDependency of ra-router-react-router-next (it is provided by the host app, like the other ra-router adapters), and declare react-router as a peerDependency of ra-no-code. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/ra-no-code/package.json | 3 ++- packages/ra-router-react-router-next/package.json | 1 + 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/ra-no-code/package.json b/packages/ra-no-code/package.json index 4af76560bd7..4874856ba04 100644 --- a/packages/ra-no-code/package.json +++ b/packages/ra-no-code/package.json @@ -37,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-router-react-router-next/package.json b/packages/ra-router-react-router-next/package.json index 9707dbeb667..fc94e553337 100644 --- a/packages/ra-router-react-router-next/package.json +++ b/packages/ra-router-react-router-next/package.json @@ -35,6 +35,7 @@ "zshy": "^0.5.0" }, "peerDependencies": { + "ra-core": "^5.0.0", "react": "^19.2.7", "react-dom": "^19.2.7", "react-router": "^8.0.0" From 0473c75e976376ca36c0179d594a6f2e987507d6 Mon Sep 17 00:00:00 2001 From: Shaoyu Meng Date: Thu, 25 Jun 2026 19:49:46 -0700 Subject: [PATCH 55/56] chore: Drop unused react-router-dom devDependency from react-admin react-admin imports from react-router (via ra-router-react-router), not react-router-dom directly, so the devDependency is unnecessary. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/react-admin/package.json | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/react-admin/package.json b/packages/react-admin/package.json index 695036560fa..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" }, From 77309a1a2b1e7d46c8cc8a762a30ddc7e6addc76 Mon Sep 17 00:00:00 2001 From: Shaoyu Meng Date: Thu, 25 Jun 2026 19:53:56 -0700 Subject: [PATCH 56/56] chore: Relock after router package dependency changes Co-Authored-By: Claude Opus 4.8 (1M context) --- yarn.lock | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/yarn.lock b/yarn.lock index f465eb2c39a..9a4bcc4ac8e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -20629,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 @@ -20671,7 +20672,6 @@ __metadata: version: 0.0.0-use.local resolution: "ra-router-react-router-next@workspace:packages/ra-router-react-router-next" dependencies: - ra-core: "npm:^5.14.7" react: "npm:^19.2.7" react-dom: "npm:^19.2.7" react-router: "npm:^8.0.0" @@ -20959,7 +20959,6 @@ __metadata: 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: