From 001d3c6bfe33f79c2ab0d24787711ae867d17db3 Mon Sep 17 00:00:00 2001 From: Shaoyu Meng Date: Mon, 22 Jun 2026 15:38:04 -0700 Subject: [PATCH 01/67] 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 | 67 ++++--- .../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, 379 insertions(+), 91 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 9b3e98fd4cc..e77e128a93f 100644 --- a/packages/ra-core/package.json +++ b/packages/ra-core/package.json @@ -65,8 +65,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 483e10111a5..7e6a5864302 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 ba22b1e663b..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 = { 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 bf6c1e5ac67..4b90b51d9c7 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 { useFormContext, 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 76dde3ea1ae..b3ed05926f0 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": "^6.0.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 f444c928e40..f71361e5ae2 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 4f7ca714dd6..bbf0bb88cd2 100644 --- a/packages/react-admin/package.json +++ b/packages/react-admin/package.json @@ -51,8 +51,7 @@ "ra-language-english": "^5.15.5", "ra-ui-materialui": "^5.15.5", "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 2f8df72e8dd..8e3458a5889 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 c1060eb05c4..5638b3933cb 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 d0e7e51af6a..6ac085ae0aa 100644 --- a/yarn.lock +++ b/yarn.lock @@ -20179,8 +20179,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 @@ -20511,8 +20514,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 7d3c64fc11a62368880276f85b8ceba6c52fad60 Mon Sep 17 00:00:00 2001 From: Shaoyu Meng Date: Mon, 22 Jun 2026 16:11:52 -0700 Subject: [PATCH 02/67] 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 bbebb724c061f34456b6f1d505d4d7f613a3d972 Mon Sep 17 00:00:00 2001 From: Shaoyu Meng Date: Mon, 22 Jun 2026 16:17:02 -0700 Subject: [PATCH 03/67] update lock file --- yarn.lock | 11 +---------- 1 file changed, 1 insertion(+), 10 deletions(-) diff --git a/yarn.lock b/yarn.lock index 6ac085ae0aa..8ba3c3deab7 100644 --- a/yarn.lock +++ b/yarn.lock @@ -20180,10 +20180,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 @@ -20394,7 +20390,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:^6.0.3" zshy: "npm:^0.5.0" peerDependencies: @@ -20515,10 +20510,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 @@ -20893,7 +20884,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.6 resolution: "react-router-dom@npm:6.30.6" dependencies: From 3e1a5b7825671cd91983545762e024217773c710 Mon Sep 17 00:00:00 2001 From: Shaoyu Meng Date: Mon, 22 Jun 2026 19:33:01 -0700 Subject: [PATCH 04/67] 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 4b46e4ffc17ee7f50312dc3e900f308ba6e1f455 Mon Sep 17 00:00:00 2001 From: Shaoyu Meng Date: Tue, 23 Jun 2026 07:13:46 -0700 Subject: [PATCH 05/67] 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 | 74 +++- 10 files changed, 707 insertions(+), 3 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..d2a25bea982 --- /dev/null +++ b/packages/ra-router-react-router-v8/package.json @@ -0,0 +1,45 @@ +{ + "name": "ra-router-react-router-v8", + "version": "5.15.2", + "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.15.2", + "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 8ba3c3deab7..5dc9f3b0d9b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5779,6 +5779,13 @@ __metadata: languageName: node linkType: hard +"@remix-run/route-pattern@npm:^0.22.1": + version: 0.22.1 + resolution: "@remix-run/route-pattern@npm:0.22.1" + checksum: 752fd1b76ce5c1c40648ab6a731c18b88ecd16e43d4333a7eb724c852df775761ed70d0caef7da7d2360425886d62b5d6d47e707f15bc922669d1c66b757cd33 + languageName: node + linkType: hard + "@remix-run/router@npm:1.23.4": version: 1.23.4 resolution: "@remix-run/router@npm:1.23.4" @@ -10274,6 +10281,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.2.1": version: 1.2.2 resolution: "cookie-signature@npm:1.2.2" @@ -20141,7 +20155,7 @@ __metadata: languageName: unknown linkType: soft -"ra-core@npm:^5.15.5, ra-core@workspace:packages/ra-core": +"ra-core@npm:^5.15.2, ra-core@npm:^5.15.5, ra-core@workspace:packages/ra-core": version: 0.0.0-use.local resolution: "ra-core@workspace:packages/ra-core" dependencies: @@ -20435,6 +20449,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.15.2" + 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" @@ -20936,6 +20966,28 @@ __metadata: languageName: node linkType: hard +"react-router@npm:^8.0.0": + version: 8.5.1 + resolution: "react-router@npm:8.5.1" + dependencies: + "@remix-run/route-pattern": "npm:^0.22.1" + cookie-es: "npm:^3.1.1" + peerDependencies: + "@types/react": ">=19.2.0" + "@types/react-dom": ">=19.2.0" + react: ">=19.2.7" + react-dom: ">=19.2.7" + peerDependenciesMeta: + "@types/react": + optional: true + "@types/react-dom": + optional: true + react-dom: + optional: true + checksum: 07503a26d5c5669d84d044e59e3a7f97dad36946dbb9ade14f243f187096bc12cea5bf7401f8681c602d9100238cef752bd3d0e6a742e1c0794e2abd20662368 + 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" @@ -23983,6 +24035,16 @@ __metadata: languageName: node linkType: hard +"typescript@npm:^5.1.3": + version: 5.9.3 + resolution: "typescript@npm:5.9.3" + bin: + tsc: bin/tsc + tsserver: bin/tsserver + checksum: 6bd7552ce39f97e711db5aa048f6f9995b53f1c52f7d8667c1abdc1700c68a76a308f579cd309ce6b53646deb4e9a1be7c813a93baaf0a28ccd536a30270e1c5 + languageName: node + linkType: hard + "typescript@npm:^6.0.3": version: 6.0.3 resolution: "typescript@npm:6.0.3" @@ -23993,6 +24055,16 @@ __metadata: languageName: node linkType: hard +"typescript@patch:typescript@npm%3A^5.1.3#optional!builtin": + version: 5.9.3 + resolution: "typescript@patch:typescript@npm%3A5.9.3#optional!builtin::version=5.9.3&hash=e012d7" + bin: + tsc: bin/tsc + tsserver: bin/tsserver + checksum: 6f7e53bf0d9702350deeb6f35e08b69cbc8b958c33e0ec77bdc0ad6a6c8e280f3959dcbfde6f5b0848bece57810696489deaaa53d75de3578ff255d168c1efbd + languageName: node + linkType: hard + "typescript@patch:typescript@npm%3A^6.0.3#optional!builtin": version: 6.0.3 resolution: "typescript@patch:typescript@npm%3A6.0.3#optional!builtin::version=6.0.3&hash=e012d7" From a2a332b90be237a5a053b0f04a964431dec99f37 Mon Sep 17 00:00:00 2001 From: Shaoyu Meng Date: Tue, 23 Jun 2026 10:05:41 -0700 Subject: [PATCH 06/67] 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 2c4844f72c64dd9b86146acf49829a32b7bfe83b Mon Sep 17 00:00:00 2001 From: Shaoyu Meng Date: Tue, 23 Jun 2026 12:43:09 -0700 Subject: [PATCH 07/67] 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 e77e128a93f..ed367a6933d 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, @@ -65,7 +60,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 f71361e5ae2..f444c928e40 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 bbf0bb88cd2..4f7ca714dd6 100644 --- a/packages/react-admin/package.json +++ b/packages/react-admin/package.json @@ -51,7 +51,8 @@ "ra-language-english": "^5.15.5", "ra-ui-materialui": "^5.15.5", "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 5dc9f3b0d9b..248395a3642 100644 --- a/yarn.lock +++ b/yarn.lock @@ -20193,7 +20193,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 @@ -20539,7 +20540,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 fe124142ff718a52f03c72eab2253c111ce26efa Mon Sep 17 00:00:00 2001 From: Shaoyu Meng Date: Tue, 23 Jun 2026 12:48:15 -0700 Subject: [PATCH 08/67] 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 ed367a6933d..df915c4ffee 100644 --- a/packages/ra-core/package.json +++ b/packages/ra-core/package.json @@ -60,7 +60,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 f444c928e40..1512a277fb4 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 4f7ca714dd6..da508697fe0 100644 --- a/packages/react-admin/package.json +++ b/packages/react-admin/package.json @@ -51,7 +51,7 @@ "ra-language-english": "^5.15.5", "ra-ui-materialui": "^5.15.5", "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 248395a3642..776168e551e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -20193,7 +20193,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 @@ -20540,7 +20540,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 b4d327caef34b1ac4f7768cff718d6e41cf7835f Mon Sep 17 00:00:00 2001 From: Shaoyu Meng Date: Tue, 23 Jun 2026 13:09:34 -0700 Subject: [PATCH 09/67] 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 1512a277fb4..7ac844690d6 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": "^6.0.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 776168e551e..6631d4a6cbb 100644 --- a/yarn.lock +++ b/yarn.lock @@ -20524,7 +20524,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:^6.0.3" zshy: "npm:^0.5.0" @@ -20541,7 +20540,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 f1b86337aad45fd3165a6f72def3909a7bbad91d Mon Sep 17 00:00:00 2001 From: Shaoyu Meng Date: Tue, 23 Jun 2026 13:19:33 -0700 Subject: [PATCH 10/67] 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 df915c4ffee..191f3f1e0d8 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 7ac844690d6..57f894f345e 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 8f84a38bdfb5197a6a33ce1ad38f5f9f4cfa9cfa Mon Sep 17 00:00:00 2001 From: Shaoyu Meng Date: Tue, 23 Jun 2026 13:56:04 -0700 Subject: [PATCH 11/67] 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 374c1fabd8a562cb37a654fb426dad2986a71631 Mon Sep 17 00:00:00 2001 From: Shaoyu Meng Date: Tue, 23 Jun 2026 14:07:40 -0700 Subject: [PATCH 12/67] 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 9caa3619cb19c8731188cbc6f263ac910efb3e9f Mon Sep 17 00:00:00 2001 From: Shaoyu Meng Date: Wed, 24 Jun 2026 15:26:02 -0700 Subject: [PATCH 13/67] 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 +- 23 files changed, 549 insertions(+), 64 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 191f3f1e0d8..c328275a707 100644 --- a/packages/ra-core/package.json +++ b/packages/ra-core/package.json @@ -64,9 +64,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", @@ -75,6 +73,7 @@ "jsonexport": "^3.2.0", "lodash": "^4.17.21", "query-string": "^7.1.3", + "ra-router-react-router": "^5.15.2", "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..e2cad0ca484 --- /dev/null +++ b/packages/ra-router-react-router/package.json @@ -0,0 +1,48 @@ +{ + "name": "ra-router-react-router", + "version": "5.15.2", + "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 57f894f345e..8b405197797 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 da508697fe0..f0e797040c9 100644 --- a/packages/react-admin/package.json +++ b/packages/react-admin/package.json @@ -49,10 +49,9 @@ "ra-core": "^5.15.5", "ra-i18n-polyglot": "^5.15.5", "ra-language-english": "^5.15.5", + "ra-router-react-router": "^5.15.3", "ra-ui-materialui": "^5.15.5", - "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 () => { From b503f0dde62811c70e87d0b2468e25b84edede87 Mon Sep 17 00:00:00 2001 From: Shaoyu Meng Date: Wed, 24 Jun 2026 15:44:14 -0700 Subject: [PATCH 14/67] 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 ba1fa46a3c7691c4134d4fc57fdbf2264ee97982 Mon Sep 17 00:00:00 2001 From: Shaoyu Meng Date: Wed, 24 Jun 2026 15:46:36 -0700 Subject: [PATCH 15/67] 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 73925a7cfca758ee8d0bc3b3bc46571396beac18 Mon Sep 17 00:00:00 2001 From: Shaoyu Meng Date: Wed, 24 Jun 2026 15:59:40 -0700 Subject: [PATCH 16/67] 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 7a13488dea8d2e9ecf02ef34fbc50d50ff8dc5e8 Mon Sep 17 00:00:00 2001 From: Shaoyu Meng Date: Wed, 24 Jun 2026 16:49:58 -0700 Subject: [PATCH 17/67] 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 ++++++++---- 8 files changed, 19 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 e2cad0ca484..687e1575e11 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.15.2", - "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": { ".": { From 95326578810746daf1ceee7147910babd0446e6c Mon Sep 17 00:00:00 2001 From: Shaoyu Meng Date: Wed, 24 Jun 2026 17:34:06 -0700 Subject: [PATCH 18/67] 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 88c66f4c9730d78d19b0025732f77be352140bea Mon Sep 17 00:00:00 2001 From: Shaoyu Meng Date: Wed, 24 Jun 2026 17:39:13 -0700 Subject: [PATCH 19/67] 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 168e2b34d72f00453072f0430247f0dcb4ce5fe4 Mon Sep 17 00:00:00 2001 From: Shaoyu Meng Date: Wed, 24 Jun 2026 18:51:25 -0700 Subject: [PATCH 20/67] 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 ++++++++++++++++++ 5 files changed, 492 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 f8e4814e3b9..172f4d78a4f 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', - { - tsconfig: { rootDir: '.' }, - 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', + { + tsconfig: { rootDir: '.' }, + 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 e2bceb51708..1b70ab57964 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 e5e77c580cd..1d629feee8e 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.15.2", + "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(); + }); + }); + }); +}); From 7c5fe93b1730b531a12be218db901a0840ef3612 Mon Sep 17 00:00:00 2001 From: Shaoyu Meng Date: Wed, 24 Jun 2026 19:00:12 -0700 Subject: [PATCH 21/67] 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 5b88c44d7409fe6c19c0ab992eef4db99ac8f34f Mon Sep 17 00:00:00 2001 From: Shaoyu Meng Date: Wed, 24 Jun 2026 22:56:25 -0700 Subject: [PATCH 22/67] 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 c219ff0ca98ec2b614501003136f2a229b4e9c67 Mon Sep 17 00:00:00 2001 From: Shaoyu Meng Date: Wed, 24 Jun 2026 23:53:03 -0700 Subject: [PATCH 23/67] 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 172f4d78a4f..dc01ca7fb3c 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/67] 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 ff2210fc0e92ec25ef9ff57dde58d23dd454d6a1 Mon Sep 17 00:00:00 2001 From: Shaoyu Meng Date: Thu, 25 Jun 2026 00:51:08 -0700 Subject: [PATCH 25/67] 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 83a07eaee4132bd7237867efb5c519f957769f5e Mon Sep 17 00:00:00 2001 From: Shaoyu Meng Date: Thu, 25 Jun 2026 00:51:53 -0700 Subject: [PATCH 26/67] 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 39394abf7c8c02f6892abccb0d3d65fcfb828531 Mon Sep 17 00:00:00 2001 From: Shaoyu Meng Date: Thu, 25 Jun 2026 01:17:46 -0700 Subject: [PATCH 27/67] 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 54e4b020447907e9c4e1febaeb958c0816893d4e Mon Sep 17 00:00:00 2001 From: Shaoyu Meng Date: Thu, 25 Jun 2026 01:22:38 -0700 Subject: [PATCH 28/67] 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 dfbf8710858054f28d862c1ad8e30c8633f27ebf Mon Sep 17 00:00:00 2001 From: Shaoyu Meng Date: Thu, 25 Jun 2026 15:18:00 -0700 Subject: [PATCH 29/67] 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 8527e1bb8eebde8c4e5ec07aab6ca293f32ba3e7 Mon Sep 17 00:00:00 2001 From: Shaoyu Meng Date: Thu, 25 Jun 2026 15:30:40 -0700 Subject: [PATCH 30/67] 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 aa6a25d5f31ed4801094b38d329b787f00cfe8ea Mon Sep 17 00:00:00 2001 From: Shaoyu Meng Date: Thu, 25 Jun 2026 16:49:22 -0700 Subject: [PATCH 31/67] 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 1ccb073570039baf9e32c50a62bcd2b345712084 Mon Sep 17 00:00:00 2001 From: Shaoyu Meng Date: Thu, 25 Jun 2026 16:54:49 -0700 Subject: [PATCH 32/67] 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 ceda54a8fd66f74360793c038a6a6caba3b8e591 Mon Sep 17 00:00:00 2001 From: Shaoyu Meng Date: Thu, 25 Jun 2026 17:00:16 -0700 Subject: [PATCH 33/67] 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 67a68be3d20526cbb099c6a0e27bb947a2a7e45e Mon Sep 17 00:00:00 2001 From: Shaoyu Meng Date: Thu, 25 Jun 2026 17:09:49 -0700 Subject: [PATCH 34/67] 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 562ddae45b28d5309ec865564d314663bfbffa77 Mon Sep 17 00:00:00 2001 From: Shaoyu Meng Date: Thu, 25 Jun 2026 17:19:21 -0700 Subject: [PATCH 35/67] 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 e1a8faed5336a62f8c6d2901d428dc2d755e5b39 Mon Sep 17 00:00:00 2001 From: Shaoyu Meng Date: Thu, 25 Jun 2026 17:26:43 -0700 Subject: [PATCH 36/67] 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 75f32f0b0fa4b7781ff3b3d4d62dafbd28315126 Mon Sep 17 00:00:00 2001 From: Shaoyu Meng Date: Thu, 25 Jun 2026 17:37:53 -0700 Subject: [PATCH 37/67] 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 e2d91086749cd17a4fe720e0f437e30db934062e Mon Sep 17 00:00:00 2001 From: Shaoyu Meng Date: Thu, 25 Jun 2026 18:10:27 -0700 Subject: [PATCH 38/67] 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 f92f9a7d3211a60333b1b3d9aa8b9e95c8f48943 Mon Sep 17 00:00:00 2001 From: Shaoyu Meng Date: Thu, 25 Jun 2026 18:23:20 -0700 Subject: [PATCH 39/67] 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 a8417d3aa0d636b63db1bd48a5bfd59c8dfe6922 Mon Sep 17 00:00:00 2001 From: Shaoyu Meng Date: Thu, 25 Jun 2026 18:29:56 -0700 Subject: [PATCH 40/67] 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 4ef6ee89c664210e852e7d34933b0c21180ee11d Mon Sep 17 00:00:00 2001 From: Shaoyu Meng Date: Thu, 25 Jun 2026 18:33:03 -0700 Subject: [PATCH 41/67] 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 4c1bb00287f930f367dbb428dc9e4380d5bba69a Mon Sep 17 00:00:00 2001 From: Shaoyu Meng Date: Thu, 25 Jun 2026 18:38:05 -0700 Subject: [PATCH 42/67] 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 9c37712f83ab2f01e6427af5eaba17a1e4f7811a Mon Sep 17 00:00:00 2001 From: Shaoyu Meng Date: Thu, 25 Jun 2026 18:48:12 -0700 Subject: [PATCH 43/67] 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 6ab05c1a070a77d6ec6afe7c2c2cdfbf78bdcd56 Mon Sep 17 00:00:00 2001 From: Shaoyu Meng Date: Thu, 25 Jun 2026 18:52:45 -0700 Subject: [PATCH 44/67] 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 168500cbe8f9f4806551d13712558a5a8fe8b479 Mon Sep 17 00:00:00 2001 From: Shaoyu Meng Date: Thu, 25 Jun 2026 18:56:29 -0700 Subject: [PATCH 45/67] 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 6de2108cb6a73c937e6c599f1d2ce1e8768d5b7a Mon Sep 17 00:00:00 2001 From: Shaoyu Meng Date: Thu, 25 Jun 2026 19:00:07 -0700 Subject: [PATCH 46/67] 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 766d8851083945bedfa11ff0beaa34ebfd8cf1c8 Mon Sep 17 00:00:00 2001 From: Shaoyu Meng Date: Thu, 25 Jun 2026 19:07:27 -0700 Subject: [PATCH 47/67] 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 a2bed846c27dc9f9a49732ed9c93909789221cc3 Mon Sep 17 00:00:00 2001 From: Shaoyu Meng Date: Thu, 25 Jun 2026 19:10:22 -0700 Subject: [PATCH 48/67] 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 26129fc1cb5760dd43e6a013237b39672e0c03e2 Mon Sep 17 00:00:00 2001 From: Shaoyu Meng Date: Thu, 25 Jun 2026 19:19:53 -0700 Subject: [PATCH 49/67] 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 2cdb154d4e2..332e84c85a2 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'; @@ -1160,7 +1160,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'; @@ -1192,7 +1192,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'; @@ -1219,7 +1220,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 = () => { @@ -1251,7 +1253,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 3a21a5320cf..9fca9f3c2f2 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 168aa172b30a086c389d034dfec3e1ad0318ae03 Mon Sep 17 00:00:00 2001 From: Shaoyu Meng Date: Thu, 25 Jun 2026 19:24:36 -0700 Subject: [PATCH 50/67] 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 332e84c85a2..162742f23f1 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 e7c33da1a9bef5baf7a7a3850fe4b49e1ce85b69 Mon Sep 17 00:00:00 2001 From: Shaoyu Meng Date: Thu, 25 Jun 2026 19:35:42 -0700 Subject: [PATCH 51/67] 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 dc01ca7fb3c..3dd24e66634 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', - { - tsconfig: { rootDir: '.' }, - 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', + { + tsconfig: { rootDir: '.' }, + useESM: true, + }, + ], + }, + moduleNameMapper, testTimeout: 60000, }; diff --git a/package.json b/package.json index 1b70ab57964..bda4567115a 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 3108da1330d5654a7b56cfad21b8465db0af2693 Mon Sep 17 00:00:00 2001 From: Shaoyu Meng Date: Thu, 25 Jun 2026 19:38:26 -0700 Subject: [PATCH 52/67] 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 1d629feee8e..42542275030 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.15.2", "react": "^19.2.7", From 01a73ed5a9be356b47549e7f9dd769198ca95e47 Mon Sep 17 00:00:00 2001 From: Shaoyu Meng Date: Thu, 25 Jun 2026 19:40:25 -0700 Subject: [PATCH 53/67] 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 42542275030..fd2b6fd20ca 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.15.2", "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 8b1e30f1224d6a238e4991d375d92032dfff5da9 Mon Sep 17 00:00:00 2001 From: Shaoyu Meng Date: Thu, 25 Jun 2026 19:44:28 -0700 Subject: [PATCH 54/67] 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 b3ed05926f0..c7677140b16 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 fd2b6fd20ca..1ff207d981f 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 1b61699b3cd265fb8602524868bd6cc6550c9502 Mon Sep 17 00:00:00 2001 From: Shaoyu Meng Date: Thu, 25 Jun 2026 19:49:46 -0700 Subject: [PATCH 55/67] 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 f0e797040c9..8e62d347860 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.15.5", "react-router": "^6.28.1", - "react-router-dom": "^6.28.1", "typescript": "^6.0.3", "zshy": "^0.5.0" }, From 89f82d9dbc42bf1b32ad1dec13678b0a3905a3f6 Mon Sep 17 00:00:00 2001 From: Shaoyu Meng Date: Thu, 25 Jun 2026 21:30:20 -0700 Subject: [PATCH 56/67] test: Harden auth e2e assertions against redirect timing flakiness The auth spec asserted on the post-logout/post-login URL with cy.url().then(url => expect(url).to.contain(...)), which evaluates the assertion exactly once before the redirect has necessarily landed, causing intermittent CI failures. Switch to the retrying cy.url().should() form already used elsewhere in the file. Co-Authored-By: Claude Opus 4.8 (1M context) --- cypress/e2e/auth.cy.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/cypress/e2e/auth.cy.js b/cypress/e2e/auth.cy.js index 6d69eb75851..5f924552fb0 100644 --- a/cypress/e2e/auth.cy.js +++ b/cypress/e2e/auth.cy.js @@ -8,7 +8,7 @@ describe('Authentication', () => { it('should go to login page after logout', () => { ListPage.navigate(); ListPage.logout(); - cy.url().then(url => expect(url).to.contain('/#/login')); + cy.url().should('contain', '/#/login'); }); it('should redirect to login page when not logged in', () => { @@ -28,7 +28,7 @@ describe('Authentication', () => { ListPage.logout(); LoginPage.login('login', 'password'); ListPage.navigate(); - cy.url().then(url => expect(url).to.contain('/#/posts')); + cy.url().should('contain', '/#/posts'); }); it('should redirect to initial url keeping query string', () => { @@ -42,7 +42,7 @@ describe('Authentication', () => { ListPage.setAsNonLogged(); cy.reload(); LoginPage.login('login', 'password'); - cy.url().then(urlAfterLogin => { + cy.url().should(urlAfterLogin => { expect(urlAfterLogin).to.contain(urlBeforeLogout); }); ListPage.commentableFilter().should('exist'); From a9a1ae846f5832facaed9f8279a22c9d01536c06 Mon Sep 17 00:00:00 2001 From: Shaoyu Meng Date: Mon, 7 Sep 2026 21:56:16 -0700 Subject: [PATCH 57/67] chore: Align router package versions and relock after rebase onto next The extracted router adapter packages were created while master was at 5.15.2; next has since released 5.15.5 and moved every package to TypeScript 6, so the new manifests, their typescript devDependency and the ra-core / react-admin dependency ranges have to follow the monorepo. The lockfile is regenerated from next's baseline so upstream resolutions are preserved and only the two new workspaces plus react-router 8 are added. Co-Authored-By: Claude Opus 5.5 --- packages/ra-core/package.json | 2 +- .../ra-router-react-router-next/package.json | 4 +- packages/ra-router-react-router/package.json | 4 +- packages/react-admin/package.json | 2 +- yarn.lock | 79 ++++++++++++------- 5 files changed, 56 insertions(+), 35 deletions(-) diff --git a/packages/ra-core/package.json b/packages/ra-core/package.json index c328275a707..6ee0b145ccb 100644 --- a/packages/ra-core/package.json +++ b/packages/ra-core/package.json @@ -73,7 +73,7 @@ "jsonexport": "^3.2.0", "lodash": "^4.17.21", "query-string": "^7.1.3", - "ra-router-react-router": "^5.15.2", + "ra-router-react-router": "^5.15.5", "react-error-boundary": "^4.0.13", "react-is": "^18.2.0 || ^19.0.0" }, diff --git a/packages/ra-router-react-router-next/package.json b/packages/ra-router-react-router-next/package.json index 1ff207d981f..129879d2d51 100644 --- a/packages/ra-router-react-router-next/package.json +++ b/packages/ra-router-react-router-next/package.json @@ -1,6 +1,6 @@ { "name": "ra-router-react-router-next", - "version": "5.15.2", + "version": "5.15.5", "description": "React Router v8 provider for react-admin (will be republished as ra-router-react-router in react-admin v6)", "files": [ "*.md", @@ -31,7 +31,7 @@ "react": "^19.2.7", "react-dom": "^19.2.7", "react-router": "^8.0.0", - "typescript": "^5.1.3", + "typescript": "^6.0.3", "zshy": "^0.5.0" }, "peerDependencies": { diff --git a/packages/ra-router-react-router/package.json b/packages/ra-router-react-router/package.json index 687e1575e11..afdf4901e17 100644 --- a/packages/ra-router-react-router/package.json +++ b/packages/ra-router-react-router/package.json @@ -1,6 +1,6 @@ { "name": "ra-router-react-router", - "version": "5.15.2", + "version": "5.15.5", "description": "React Router v6/v7 provider for react-admin (the default router adapter)", "files": [ "*.md", @@ -24,7 +24,7 @@ "devDependencies": { "react-router": "^6.28.1", "react-router-dom": "^6.28.1", - "typescript": "^5.1.3", + "typescript": "^6.0.3", "zshy": "^0.5.0" }, "peerDependencies": { diff --git a/packages/react-admin/package.json b/packages/react-admin/package.json index 8e62d347860..30ff0605625 100644 --- a/packages/react-admin/package.json +++ b/packages/react-admin/package.json @@ -48,7 +48,7 @@ "ra-core": "^5.15.5", "ra-i18n-polyglot": "^5.15.5", "ra-language-english": "^5.15.5", - "ra-router-react-router": "^5.15.3", + "ra-router-react-router": "^5.15.5", "ra-ui-materialui": "^5.15.5", "react-hook-form": "^7.72.0" }, diff --git a/yarn.lock b/yarn.lock index 6631d4a6cbb..2fcd21341a3 100644 --- a/yarn.lock +++ b/yarn.lock @@ -20155,7 +20155,7 @@ __metadata: languageName: unknown linkType: soft -"ra-core@npm:^5.15.2, ra-core@npm:^5.15.5, ra-core@workspace:packages/ra-core": +"ra-core@npm:^5.15.5, ra-core@workspace:packages/ra-core": version: 0.0.0-use.local resolution: "ra-core@workspace:packages/ra-core" dependencies: @@ -20177,6 +20177,7 @@ __metadata: jsonexport: "npm:^3.2.0" lodash: "npm:^4.17.21" query-string: "npm:^7.1.3" + ra-router-react-router: "npm:^5.15.5" react: "npm:^18.3.1" react-dom: "npm:^18.3.1" react-error-boundary: "npm:^4.0.13" @@ -20193,8 +20194,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 @@ -20412,6 +20411,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 @@ -20450,13 +20450,14 @@ __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.15.2" + react: "npm:^19.2.7" + react-dom: "npm:^19.2.7" react-router: "npm:^8.0.0" - typescript: "npm:^5.1.3" + typescript: "npm:^6.0.3" zshy: "npm:^0.5.0" peerDependencies: ra-core: ^5.0.0 @@ -20466,6 +20467,22 @@ __metadata: languageName: unknown linkType: soft +"ra-router-react-router@npm:^5.15.5, 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:^6.0.3" + zshy: "npm:^0.5.0" + peerDependencies: + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + react-router: ^6.28.1 || ^7.1.1 + react-router-dom: ^6.28.1 || ^7.1.1 + languageName: unknown + linkType: soft + "ra-router-tanstack@workspace:packages/ra-router-tanstack": version: 0.0.0-use.local resolution: "ra-router-tanstack@workspace:packages/ra-router-tanstack" @@ -20539,7 +20556,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 @@ -20701,10 +20717,10 @@ __metadata: ra-data-fakerest: "npm:^5.15.5" ra-i18n-polyglot: "npm:^5.15.5" ra-language-english: "npm:^5.15.5" + ra-router-react-router: "npm:^5.15.5" ra-ui-materialui: "npm:^5.15.5" react-hook-form: "npm:^7.72.0" react-router: "npm:^6.28.1" - react-router-dom: "npm:^6.28.1" typescript: "npm:^6.0.3" zshy: "npm:^0.5.0" peerDependencies: @@ -20774,6 +20790,17 @@ __metadata: languageName: node linkType: hard +"react-dom@npm:^19.2.7": + version: 19.3.1 + resolution: "react-dom@npm:19.3.1" + dependencies: + scheduler: "npm:^0.28.0" + peerDependencies: + react: ^19.3.1 + checksum: 382700916313b2ebc46acee80642f300bb8601eac6a9e7fefade5b8b30e66b05bf3aaa56a3c964589fcd578d27b486e847470c2697d879264e766661ff49fac7 + languageName: node + linkType: hard + "react-dropzone@npm:^14.2.3": version: 14.2.3 resolution: "react-dropzone@npm:14.2.3" @@ -21038,6 +21065,13 @@ __metadata: languageName: node linkType: hard +"react@npm:^19.2.7": + version: 19.3.1 + resolution: "react@npm:19.3.1" + checksum: f0ed036d1223562c09c8347858ca712733ea6e27a30db709ee9c3f3f176d3e9e767afce735d59956addf3d030102e256bba82dea635fc1bdb0e01107e98fef10 + languageName: node + linkType: hard + "read-cmd-shim@npm:^7.0.0": version: 7.0.1 resolution: "read-cmd-shim@npm:7.0.1" @@ -22156,6 +22190,13 @@ __metadata: languageName: node linkType: hard +"scheduler@npm:^0.28.0": + version: 0.28.0 + resolution: "scheduler@npm:0.28.0" + checksum: 783c8e5ce89d41273a02ed6dc0e3d24bd60e05c9e8f7e3adfc6c82e7c69935500d90e343f1ee26d592fc7504e8a2532573c6368def3e93bfe812391fa53efaf4 + languageName: node + linkType: hard + "section-matter@npm:^1.0.0": version: 1.0.0 resolution: "section-matter@npm:1.0.0" @@ -24035,16 +24076,6 @@ __metadata: languageName: node linkType: hard -"typescript@npm:^5.1.3": - version: 5.9.3 - resolution: "typescript@npm:5.9.3" - bin: - tsc: bin/tsc - tsserver: bin/tsserver - checksum: 6bd7552ce39f97e711db5aa048f6f9995b53f1c52f7d8667c1abdc1700c68a76a308f579cd309ce6b53646deb4e9a1be7c813a93baaf0a28ccd536a30270e1c5 - languageName: node - linkType: hard - "typescript@npm:^6.0.3": version: 6.0.3 resolution: "typescript@npm:6.0.3" @@ -24055,16 +24086,6 @@ __metadata: languageName: node linkType: hard -"typescript@patch:typescript@npm%3A^5.1.3#optional!builtin": - version: 5.9.3 - resolution: "typescript@patch:typescript@npm%3A5.9.3#optional!builtin::version=5.9.3&hash=e012d7" - bin: - tsc: bin/tsc - tsserver: bin/tsserver - checksum: 6f7e53bf0d9702350deeb6f35e08b69cbc8b958c33e0ec77bdc0ad6a6c8e280f3959dcbfde6f5b0848bece57810696489deaaa53d75de3578ff255d168c1efbd - languageName: node - linkType: hard - "typescript@patch:typescript@npm%3A^6.0.3#optional!builtin": version: 6.0.3 resolution: "typescript@patch:typescript@npm%3A6.0.3#optional!builtin::version=6.0.3&hash=e012d7" From e15c2d086e61994465140eb29b8573dbb2d09d45 Mon Sep 17 00:00:00 2001 From: Shaoyu Meng Date: Sun, 1 Nov 2026 21:47:24 -0800 Subject: [PATCH 58/67] fix: Make router adapter packages compile and test under TypeScript 6 next turned on `strict` (and with it strictFunctionTypes) in the root tsconfig, so react-router's Route, whose props are a union requiring `index: true` on index routes, no longer satisfies ComponentType. Cast it like next does for the in-core adapter that these packages replace. TypeScript 6 also requires an explicit rootDir, which next sets for ts-jest in the root jest config; the ESM jest project of ra-router-react-router-next needs the same, and ts-jest's isolatedModules option goes away now that the root tsconfig sets it. Co-Authored-By: Claude Opus 5.5 --- packages/ra-router-react-router-next/jest.config.cjs | 2 +- .../src/reactRouterNextProvider.tsx | 2 +- .../ra-router-react-router/src/reactRouterProvider.tsx | 9 ++++++++- 3 files changed, 10 insertions(+), 3 deletions(-) diff --git a/packages/ra-router-react-router-next/jest.config.cjs b/packages/ra-router-react-router-next/jest.config.cjs index 58a59aa6b72..15e2a5fad0b 100644 --- a/packages/ra-router-react-router-next/jest.config.cjs +++ b/packages/ra-router-react-router-next/jest.config.cjs @@ -46,11 +46,11 @@ module.exports = { '^.+\\.[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: { + rootDir: '.', module: 'ESNext', target: 'ESNext', }, diff --git a/packages/ra-router-react-router-next/src/reactRouterNextProvider.tsx b/packages/ra-router-react-router-next/src/reactRouterNextProvider.tsx index 67e6851c770..b4654385ac6 100644 --- a/packages/ra-router-react-router-next/src/reactRouterNextProvider.tsx +++ b/packages/ra-router-react-router-next/src/reactRouterNextProvider.tsx @@ -222,7 +222,7 @@ export const reactRouterNextProvider: RouterProvider = { // Components Link, Navigate, - Route, + Route: Route as RouterProvider['Route'], Routes, Outlet, diff --git a/packages/ra-router-react-router/src/reactRouterProvider.tsx b/packages/ra-router-react-router/src/reactRouterProvider.tsx index 42eba312913..75d7a19e810 100644 --- a/packages/ra-router-react-router/src/reactRouterProvider.tsx +++ b/packages/ra-router-react-router/src/reactRouterProvider.tsx @@ -40,6 +40,13 @@ type RouterNavigateFunction = ( options?: { replace?: boolean; state?: any } ) => void; +interface RouterRouteProps { + path?: string; + element?: ReactNode; + children?: ReactNode; + index?: boolean; +} + interface RouterWrapperProps { basename?: string; children: ReactNode; @@ -266,7 +273,7 @@ export const reactRouterProvider = { // Components Link, Navigate, - Route, + Route: Route as React.ComponentType, Routes, Outlet, From 9fe8f938e3632d4e5d5d7ec378ce73f8e6311393 Mon Sep 17 00:00:00 2001 From: smeng9 <38666763+smeng9@users.noreply.github.com> Date: Mon, 2 Nov 2026 15:58:05 -0800 Subject: [PATCH 59/67] Apply suggestion from @ThieryMichel Co-authored-by: Thiery Michel --- docs/Routing.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/Routing.md b/docs/Routing.md index 2634b8aef49..e5ae14cdb4d 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) 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`](#react-router-configuration) 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: From 94a3e802b5a920e3953f7e6fb56b8fdcf7577a3d Mon Sep 17 00:00:00 2001 From: Shaoyu Meng Date: Mon, 2 Nov 2026 14:04:18 -0800 Subject: [PATCH 60/67] docs: Document the bundled react-router v7 copy and link the v8 adapter docs ra-core keeps react-router v6/v7 as the default router for backward compatibility, so it always depends on ra-router-react-router, which brings its own react-router v7. Apps on the v8 adapter therefore ship both versions. This can't be fixed without a breaking change, so the React Router v8 page now says so. ra-core's TestMemoryRouter imports react-router directly, which only worked through hoisting. Declare react-router as a peer dependency. It is optional so npm doesn't auto-install react-router 8 (which needs React 19) in React 18 apps that don't list react-router themselves. Also add the React Router v8 page to the Jekyll navigation and add a headless version in docs_headless. Co-Authored-By: Claude Opus 5.5 --- docs/ReactRouterV8.md | 2 + docs/navigation.html | 1 + docs_headless/astro.config.mjs | 7 +- .../src/content/docs/ReactRouterV8.md | 66 +++++++++++++++++++ docs_headless/src/content/docs/Routing.md | 2 +- packages/ra-core/package.json | 8 ++- yarn.lock | 4 ++ 7 files changed, 87 insertions(+), 3 deletions(-) create mode 100644 docs_headless/src/content/docs/ReactRouterV8.md diff --git a/docs/ReactRouterV8.md b/docs/ReactRouterV8.md index 3ba4b8c49c1..f250944fee0 100644 --- a/docs/ReactRouterV8.md +++ b/docs/ReactRouterV8.md @@ -21,6 +21,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`. +**Note:** For backward compatibility, React Router v6/v7 remains the default router, so `ra-core` always depends on the default `ra-router-react-router` adapter, which brings its own copy of `react-router` v7. As a consequence, an application using `ra-router-react-router-next` installs both React Router v7 and v8, and the v7 copy ends up in the application bundle. The application only uses React Router v8 at runtime, but the bundle is larger. This will be fixed in react-admin v6, when `ra-router-react-router-next` becomes the default adapter. + ## Configuration Set the `` to `reactRouterNextProvider`: diff --git a/docs/navigation.html b/docs/navigation.html index 07bf43b380e..a2f58513a58 100644 --- a/docs/navigation.html +++ b/docs/navigation.html @@ -348,6 +348,7 @@
  • Caching
  • Unit Testing
  • TanStack Router
  • +
  • React Router v8
  • Advanced tutorials
  • diff --git a/docs_headless/astro.config.mjs b/docs_headless/astro.config.mjs index e78e338b9d2..72f6558b20d 100644 --- a/docs_headless/astro.config.mjs +++ b/docs_headless/astro.config.mjs @@ -317,7 +317,12 @@ export default defineConfig({ }, { label: 'Recipes', - items: ['caching', 'unittesting', 'tanstackrouter'], + items: [ + 'caching', + 'unittesting', + 'tanstackrouter', + 'reactrouterv8', + ], }, ], components: { diff --git a/docs_headless/src/content/docs/ReactRouterV8.md b/docs_headless/src/content/docs/ReactRouterV8.md new file mode 100644 index 00000000000..139dce5c1f6 --- /dev/null +++ b/docs_headless/src/content/docs/ReactRouterV8.md @@ -0,0 +1,66 @@ +--- +title: "React Router v8 Integration" +sidebar: + order: 8 +--- + +By default, ra-core is powered by [React Router](https://reactrouter.com/) v6/v7 through the `ra-router-react-router` adapter, which is installed automatically. React Router v8 merged the former `react-router-dom` package into `react-router` and requires **React 19**, so support for it ships as a separate, opt-in adapter package: `ra-router-react-router-next`. + +**New projects are encouraged to run on React Router v8 using `ra-router-react-router-next`.** The React Router v6/v7 adapter remains the default for backward compatibility, and **`ra-router-react-router-next` will become the default `ra-router-react-router` in ra-core v6.** + +Use this package when your application runs on React Router v8. + +## Installation + +```bash +npm install ra-router-react-router-next react-router@^8 +# or +yarn add ra-router-react-router-next react-router@^8 +``` + +React Router v8 requires React 19. Make sure your application uses `react@^19.2.7` and `react-dom@^19.2.7`. + +**Note:** For backward compatibility, React Router v6/v7 remains the default router, so `ra-core` always depends on the default `ra-router-react-router` adapter, which brings its own copy of `react-router` v7. As a consequence, an application using `ra-router-react-router-next` installs both React Router v7 and v8, and the v7 copy ends up in the application bundle. The application only uses React Router v8 at runtime, but the bundle is larger. This will be fixed in ra-core v6, when `ra-router-react-router-next` becomes the default adapter. + +## Configuration + +Set the `` to `reactRouterNextProvider`: + +```jsx +import { CoreAdmin, Resource } from 'ra-core'; +import { reactRouterNextProvider } from 'ra-router-react-router-next'; +import { dataProvider } from './dataProvider'; +import { PostList, PostEdit, PostCreate, PostShow } from './posts'; + +const App = () => ( + + + +); + +export default App; +``` + +That's it! Ra-core will now use React Router v8 for all routing operations. + +## Standalone Mode + +When using `reactRouterNextProvider` without an existing React Router, ra-core creates its own hash router automatically (URLs like `/#/posts`). This is called **standalone mode**. This is the simplest setup and requires no additional configuration. + +## Embedded Mode + +If your application already uses React Router, you can embed ra-core inside it. Ra-core detects the existing router context and uses it instead of creating its own. + +## When To Use This Package + +- Use the **default** `ra-router-react-router` adapter (installed automatically, no extra setup) if your app runs on React Router v6 or v7. +- Use **`ra-router-react-router-next`** if your app runs on React Router v8 (and therefore React 19). This is the recommended choice for new projects, and it will become the default in ra-core v6. diff --git a/docs_headless/src/content/docs/Routing.md b/docs_headless/src/content/docs/Routing.md index e01e143893a..ff40ff46fbe 100644 --- a/docs_headless/src/content/docs/Routing.md +++ b/docs_headless/src/content/docs/Routing.md @@ -161,7 +161,7 @@ See [the troubleshooting section](#troubleshooting) for a solution. ## Using A Different Router Library -Ra-core 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. +Ra-core 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) 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 6ee0b145ccb..84856d9d3da 100644 --- a/packages/ra-core/package.json +++ b/packages/ra-core/package.json @@ -64,7 +64,13 @@ "@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-hook-form": "^7.72.0", + "react-router": "^6.28.1 || ^7.1.1 || ^8.0.0" + }, + "peerDependenciesMeta": { + "react-router": { + "optional": true + } }, "dependencies": { "date-fns": "^3.6.0", diff --git a/yarn.lock b/yarn.lock index 2fcd21341a3..4016b8801a1 100644 --- a/yarn.lock +++ b/yarn.lock @@ -20194,6 +20194,10 @@ __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 + peerDependenciesMeta: + react-router: + optional: true languageName: unknown linkType: soft From 39f2703838ace72b53afe713e5413dda7e89b0fe Mon Sep 17 00:00:00 2001 From: Shaoyu Meng Date: Mon, 2 Nov 2026 14:24:55 -0800 Subject: [PATCH 61/67] test: Run react-router-next tests as a separate script so test-unit arguments reach jest test-unit and test-unit-ci chained the main jest run and the react-router-next jest run with &&. Yarn appends extra arguments to the end of the script, so they only reached the second run: make test-unit-watch watched only the v8 tests, and a file filter passed to yarn test-unit only filtered the v8 run. Restore test-unit and test-unit-ci to the single jest call they are on next, and move the ESM run into test-unit-react-router-next and test-unit-react-router-next-ci. make test-unit and yarn test call both, so CI still runs the v8 tests. Co-Authored-By: Claude Opus 5.5 --- Makefile | 4 ++-- package.json | 8 +++++--- packages/ra-router-react-router-next/jest.config.cjs | 7 ++++--- 3 files changed, 11 insertions(+), 8 deletions(-) diff --git a/Makefile b/Makefile index 6be609614c6..d334beccddf 100644 --- a/Makefile +++ b/Makefile @@ -170,11 +170,11 @@ test: build test-unit lint test-e2e ## launch all tests test-unit: ## launch unit tests @if [ "$(CI)" != "true" ]; then \ echo "Running unit tests..."; \ - yarn test-unit; \ + yarn test-unit && yarn test-unit-react-router-next; \ fi @if [ "$(CI)" = "true" ]; then \ echo "Running unit tests in CI..."; \ - yarn test-unit-ci; \ + yarn test-unit-ci && yarn test-unit-react-router-next-ci; \ fi test-unit-watch: ## launch unit tests and watch for changes diff --git a/package.json b/package.json index bda4567115a..6e128674a67 100644 --- a/package.json +++ b/package.json @@ -5,11 +5,13 @@ "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 && 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-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-react-router-next": "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", + "test-unit-react-router-next-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 --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", + "test": "yarn test-unit && yarn test-unit-react-router-next && yarn test-e2e", "doc": "cd docs && jekyll server . --watch --host 0.0.0.0", "lint": "eslint --ext .js,.ts,.tsx \"./packages/**/src/**/*.{js,ts,tsx}\" \"./examples/**/src/**/*.{js,ts,tsx}\" \"./cypress/**/*.{js,ts,tsx}\"", "prettier": "prettier --config ./.prettierrc.js --write --list-different \"packages/*/src/**/*.{js,json,ts,tsx,css,md}\" \"examples/*/src/**/*.{js,ts,json,tsx,css,md}\" \"cypress/**/*.{js,ts,json,tsx,css,md}\"", diff --git a/packages/ra-router-react-router-next/jest.config.cjs b/packages/ra-router-react-router-next/jest.config.cjs index 15e2a5fad0b..a16e1b3f8f8 100644 --- a/packages/ra-router-react-router-next/jest.config.cjs +++ b/packages/ra-router-react-router-next/jest.config.cjs @@ -1,12 +1,13 @@ 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`. +// Package-local jest config for ra-router-react-router-next. The root jest config +// ignores this package; it runs separately through the +// `test-unit-react-router-next` scripts. // // 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`), +// - runs in ESM mode (the `test-unit-react-router-next` 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. From 37ed769d95b2c6437041c6e088a1af8d5b479937 Mon Sep 17 00:00:00 2001 From: Shaoyu Meng Date: Mon, 2 Nov 2026 16:01:41 -0800 Subject: [PATCH 62/67] fix: Stop the react-router adapter from prepending the basename itself The default v6/v7 adapter re-declared a private BasenameContext and made its Link, Navigate and useNavigate prepend the basename. Since a later refactor filled that context in standalone mode too, where the hash router already applies the basename, links and navigation in a standalone admin with a basename went to /admin/admin/... In embedded mode it also rewrote hand-written absolute paths, so a link to a host route like "/" became "/admin/", which the in-core adapter on next never did. Restore the in-core adapter's behavior: the hash router applies the basename in standalone mode, and ra-core's useCreatePath, useRedirect and auth hooks apply it in embedded mode. The stories' resource links now use useCreatePath so the embedded story still navigates under /admin. Add standalone tests for Link, useNavigate and Navigate with a basename, and embedded tests for useCreatePath paths and hand-written paths. They render inline instead of reusing stories, to keep the stories file mirrored with the TanStack adapter. Co-Authored-By: Claude Opus 5.5 --- .../src/reactRouterProvider.spec.tsx | 188 +++++++++++++++++- .../src/reactRouterProvider.stories.tsx | 79 +++++--- .../src/reactRouterProvider.tsx | 146 +------------- 3 files changed, 244 insertions(+), 169 deletions(-) diff --git a/packages/ra-router-react-router/src/reactRouterProvider.spec.tsx b/packages/ra-router-react-router/src/reactRouterProvider.spec.tsx index c7f059ab64c..1ae734535ac 100644 --- a/packages/ra-router-react-router/src/reactRouterProvider.spec.tsx +++ b/packages/ra-router-react-router/src/reactRouterProvider.spec.tsx @@ -7,6 +7,17 @@ import { cleanup, } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; +import fakeDataProvider from 'ra-data-fakerest'; +import { RouterProvider } from 'react-router'; +import { createHashRouter } from 'react-router-dom'; +import { + CoreAdmin, + LinkBase, + ListBase, + Resource, + useCreatePath, + useNavigate, +} from 'ra-core'; import { Basic, EmbeddedInReactRouter, @@ -31,7 +42,7 @@ import { } from './reactRouterProvider.stories'; import { reactRouterProvider } from './reactRouterProvider'; -const { matchPath } = reactRouterProvider; +const { matchPath, Navigate } = reactRouterProvider; describe('reactRouterProvider', () => { // Reset hash before each test to ensure clean state @@ -540,9 +551,184 @@ describe('reactRouterProvider', () => { ).toBeInTheDocument(); }); }); + + describe('with a basename', () => { + // The hash router carries the basename, so links and + // navigation must apply it exactly once. + const dataProvider = fakeDataProvider({ + posts: [{ id: 1, title: 'Post #1' }], + }); + + const PostList = () => { + const navigate = useNavigate(); + const [redirect, setRedirect] = React.useState(false); + return ( + ( + <> + {data?.map(record => ( + + {record.title} + + ))} + + + {redirect && ( + + )} + + )} + /> + ); + }; + + const PostShow = () =>

    Post Details

    ; + + const StandaloneWithBasename = () => ( + + + + ); + + beforeEach(() => { + window.location.hash = '#/admin/posts'; + }); + + it('should apply the basename once to Link', async () => { + render(); + const link = await screen.findByText('Post #1'); + expect(link.getAttribute('href')).toBe( + '#/admin/posts/1/show' + ); + }); + + it('should apply the basename once to useNavigate', async () => { + render(); + fireEvent.click( + await screen.findByText('Navigate to Post #1') + ); + await waitFor(() => { + expect(window.location.hash).toBe( + '#/admin/posts/1/show' + ); + }); + await screen.findByText('Post Details'); + }); + + it('should apply the basename once to Navigate', async () => { + render(); + fireEvent.click( + await screen.findByText('Redirect to Post #1') + ); + await waitFor(() => { + expect(window.location.hash).toBe( + '#/admin/posts/1/show' + ); + }); + await screen.findByText('Post Details'); + }); + }); }); describe('embedded mode', () => { + describe('with a basename', () => { + // As in upstream react-admin, ra-core applies the basename + // (useCreatePath, useRedirect, auth hooks); the adapter + // leaves hand-written absolute paths untouched. + const dataProvider = fakeDataProvider({ + posts: [{ id: 1, title: 'Post #1' }], + }); + + const PostList = () => { + const createPath = useCreatePath(); + return ( + ( + <> + {data?.map(record => ( + + {record.title} + + ))} + Back to host + + )} + /> + ); + }; + + const EmbeddedWithBasename = () => { + const router = React.useMemo( + () => + createHashRouter([ + { + path: '/admin/*', + element: ( + + + + ), + }, + ]), + [] + ); + return ; + }; + + beforeEach(() => { + window.location.hash = '#/admin/posts'; + }); + + it('should apply the basename once to paths from useCreatePath', async () => { + render(); + const link = await screen.findByText('Post #1'); + expect(link.getAttribute('href')).toBe( + '#/admin/posts/1/show' + ); + }); + + it('should not apply the basename to hand-written absolute paths', async () => { + render(); + const link = await screen.findByText('Back to host'); + expect(link.getAttribute('href')).toBe('#/'); + }); + }); + it('should render home page initially', async () => { render(); await waitFor(() => { diff --git a/packages/ra-router-react-router/src/reactRouterProvider.stories.tsx b/packages/ra-router-react-router/src/reactRouterProvider.stories.tsx index 32d5b8977e2..72cda868197 100644 --- a/packages/ra-router-react-router/src/reactRouterProvider.stories.tsx +++ b/packages/ra-router-react-router/src/reactRouterProvider.stories.tsx @@ -15,6 +15,7 @@ import { EditBase, CreateBase, useRecordContext, + useCreatePath, CoreAdmin, Resource, CustomRoutes, @@ -54,38 +55,56 @@ const dataProvider = fakeDataProvider( process.env.NODE_ENV === 'development' ); -const PostList = () => ( - -
    -

    Posts

    - ( - - {record.title} - - )} - /> - -
    -
    -); - -const PostShow = () => ( - ( +// Links use useCreatePath so that they carry the basename when react-admin +// is embedded under a sub path (see EmbeddedInReactRouter) +const PostList = () => { + const createPath = useCreatePath(); + return ( +
    -

    Post Details

    - - ID: {record?.id} - Title: {record?.title} - Body: {record?.body} - - Back to list +

    Posts

    + ( + + {record.title} + + )} + /> +
    - )} - /> -); +
    + ); +}; + +const PostShow = () => { + const createPath = useCreatePath(); + return ( + ( +
    +

    Post Details

    + + ID: {record?.id} + Title: {record?.title} + Body: {record?.body} + + + Back to list + +
    + )} + /> + ); +}; const PostEdit = () => ( diff --git a/packages/ra-router-react-router/src/reactRouterProvider.tsx b/packages/ra-router-react-router/src/reactRouterProvider.tsx index 75d7a19e810..72bb8f1dad5 100644 --- a/packages/ra-router-react-router/src/reactRouterProvider.tsx +++ b/packages/ra-router-react-router/src/reactRouterProvider.tsx @@ -1,12 +1,5 @@ import * as React from 'react'; -import { - useContext, - useEffect, - useRef, - forwardRef, - createContext, - ReactNode, -} from 'react'; +import { useContext, useEffect, useRef, ReactNode } from 'react'; import { useNavigate as useReactRouterNavigate, useLocation, @@ -14,7 +7,7 @@ import { useBlocker, useMatch, useInRouterContext, - Navigate as ReactRouterNavigate, + Navigate, Route, Routes, Outlet, @@ -23,13 +16,8 @@ import { UNSAFE_DataRouterContext, UNSAFE_DataRouterStateContext, type FutureConfig, - type NavigateProps, } from 'react-router'; -import { - Link as ReactRouterDomLink, - createHashRouter, - type LinkProps, -} from 'react-router-dom'; +import { Link, createHashRouter } 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), @@ -59,13 +47,6 @@ 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 }; @@ -91,7 +72,6 @@ const useCanBlock = (): boolean => { */ const useNavigate = (): RouterNavigateFunction => { const navigate = useReactRouterNavigate(); - const basename = useBasename(); const navigateRef = useRef( navigate as RouterNavigateFunction ); @@ -101,111 +81,9 @@ const useNavigate = (): RouterNavigateFunction => { }, [navigate]); // Return a stable function that always calls the latest navigate - return React.useCallback( - (...args: Parameters) => { - const [to, ...rest] = args; - - // Handle numeric navigation (go back/forward) - if (typeof to === 'number') { - return navigateRef.current(to, ...rest); - } - - // Helper to prepend basename to absolute paths - // Only prepend if path doesn't already start with basename - const resolvePath = (path: string) => { - if (!basename || !path.startsWith('/')) return path; - // Don't prepend if path already includes basename - if (path.startsWith(basename + '/') || path === basename) { - return path; - } - return `${basename}${path}`; - }; - - // Handle object navigation { pathname?, search?, hash?, state? } - // This covers both { pathname: '/foo' } and { search: '?bar=1' } - if (typeof to === 'object' && to !== null) { - // If no pathname provided, keep current pathname - return navigateRef.current( - to.pathname - ? { ...to, pathname: resolvePath(to.pathname) } - : to, - ...rest - ); - } - - // Handle string path - const resolvedPath = resolvePath(to as string); - return navigateRef.current(resolvedPath, ...rest); - }, - [basename] - ) as RouterNavigateFunction; -}; - -const Link = forwardRef( - ({ to, ...rest }, ref) => { - const basename = useBasename(); - - // Helper to prepend basename to absolute paths - const resolvePath = (path: string) => { - if (!basename || !path.startsWith('/')) return path; - if (path.startsWith(basename + '/') || path === basename) { - return path; - } - return `${basename}${path}`; - }; - - // Handle object `to` (e.g., { pathname: '/path', search: '?foo=bar' }) - let resolvedTo: typeof to; - if (typeof to === 'object' && to !== null) { - // If no pathname provided, keep it as-is to stay on current page - resolvedTo = to.pathname - ? { ...to, pathname: resolvePath(to.pathname) } - : to; - } else { - resolvedTo = resolvePath(to as string); - } - return ; - } -); -Link.displayName = 'Link'; - -const Navigate = ({ to, ...rest }: NavigateProps) => { - const basename = useBasename(); - const currentLocation = useLocation(); - - // Handle both string and object forms of `to` - let resolvedPath: string; - - if (typeof to === 'string') { - resolvedPath = to; - } else { - // If no pathname provided, use current pathname to stay on current page - resolvedPath = to.pathname ?? currentLocation.pathname; - - // Append search and hash directly to the path to preserve the raw - // query string format - if (to.search) { - resolvedPath += to.search.startsWith('?') - ? to.search - : `?${to.search}`; - } - if (to.hash) { - resolvedPath += to.hash.startsWith('#') ? to.hash : `#${to.hash}`; - } - } - - // Prepend basename to the path - // Only prepend if path doesn't already start with basename - if (basename && resolvedPath.startsWith('/')) { - if ( - !resolvedPath.startsWith(basename + '/') && - resolvedPath !== basename - ) { - resolvedPath = `${basename}${resolvedPath}`; - } - } - - return ; + return React.useCallback((...args: Parameters) => { + return navigateRef.current(...args); + }, []) as RouterNavigateFunction; }; /** @@ -242,18 +120,10 @@ const RouterWrapper = ({ basename, children }: RouterWrapperProps) => { const isInRouter = useInRouterContext(); if (isInRouter) { - return ( - - {children} - - ); + return <>{children}; } - return ( - - {children} - - ); + return {children}; }; /** From 531f384233d2707ccb2706370008f9a72471e6d2 Mon Sep 17 00:00:00 2001 From: Shaoyu Meng Date: Mon, 2 Nov 2026 16:10:49 -0800 Subject: [PATCH 63/67] docs: Add an embedded mode example and basename behavior to the React Router v8 page The Embedded Mode section had no example and didn't cover basename. Add an example mounting the admin under /admin in a React Router v8 app, and explain that ra-router-react-router-next adds the basename to hand-written absolute paths like the TanStack adapter, while the default v6/v7 adapter leaves them untouched. Apps moving an embedded admin from the default adapter need to know that links meant for the host app now point inside the admin. Co-Authored-By: Claude Opus 5.5 --- docs/ReactRouterV8.md | 68 +++++++++++++++++++ .../src/content/docs/ReactRouterV8.md | 68 +++++++++++++++++++ 2 files changed, 136 insertions(+) diff --git a/docs/ReactRouterV8.md b/docs/ReactRouterV8.md index f250944fee0..72cc8e5b65f 100644 --- a/docs/ReactRouterV8.md +++ b/docs/ReactRouterV8.md @@ -51,10 +51,78 @@ That's it! React-admin will now use React Router v8 for all routing operations. 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. +If you set the `` prop in standalone mode, the hash router applies it to every route, so write paths without it (e.g. ``, not ``). + ## Embedded Mode If your application already uses React Router, you can embed react-admin inside it. React-admin detects the existing router context and uses it instead of creating its own. +```tsx +import * as React from 'react'; +import { createHashRouter, RouterProvider, Outlet, Link } from 'react-router'; +import { Admin, Resource } from 'react-admin'; +import { reactRouterNextProvider } from 'ra-router-react-router-next'; +import { dataProvider } from './dataProvider'; +import { PostList, PostEdit } from './posts'; + +const Layout = () => ( +
    + + +
    +); + +const StoreAdmin = () => ( + + + +); + +const router = createHashRouter([ + { + path: '/', + element: , + children: [ + { index: true, element:
    Welcome to my app!
    }, + // Mount react-admin at /admin. The splat matches /admin/posts, /admin/posts/1, etc. + { path: 'admin/*', element: }, + ], + }, +]); + +const App = () => ; + +export default App; +``` + +**Important**: When embedding react-admin, set the `basename` prop to match the path where react-admin is mounted. In the example above, react-admin is mounted at `/admin`, so `basename="/admin"`. + +### Links Under The Basename + +In embedded mode, `ra-router-react-router-next` adds the basename to the absolute paths you pass to react-admin's routing components and hooks (``, ``, ``, `useNavigate`), unless the path already starts with the basename. This is the same behavior as the [TanStack Router adapter](./TanStackRouter.md). With `basename="/admin"`: + +```tsx +import { Link } from 'react-admin'; + +// Points to /admin/posts/1/show +Post #1 +// Points to /admin/posts (already prefixed, left as is) +Posts +// Points to /admin/, not to the host app's home page +Home +``` + +To link to a route of the host application from inside react-admin, use React Router's own `` (`import { Link } from 'react-router'`), which doesn't add the basename. + +**Note:** The default `ra-router-react-router` adapter (React Router v6/v7) behaves differently: it leaves absolute paths untouched. Only the paths built by react-admin itself (with `useCreatePath`, redirections, and authentication redirects) include the basename, so `` points to `/posts/1/show` in the host application. If you migrate an embedded admin from the default adapter, check the hand-written links that point outside react-admin, as they now point inside it. + ## When To Use This Package - Use the **default** `ra-router-react-router` adapter (installed automatically, no extra setup) if your app runs on React Router v6 or v7. diff --git a/docs_headless/src/content/docs/ReactRouterV8.md b/docs_headless/src/content/docs/ReactRouterV8.md index 139dce5c1f6..a67cdd5d088 100644 --- a/docs_headless/src/content/docs/ReactRouterV8.md +++ b/docs_headless/src/content/docs/ReactRouterV8.md @@ -56,10 +56,78 @@ That's it! Ra-core will now use React Router v8 for all routing operations. When using `reactRouterNextProvider` without an existing React Router, ra-core creates its own hash router automatically (URLs like `/#/posts`). This is called **standalone mode**. This is the simplest setup and requires no additional configuration. +If you set the `` prop in standalone mode, the hash router applies it to every route, so write paths without it (e.g. ``, not ``). + ## Embedded Mode If your application already uses React Router, you can embed ra-core inside it. Ra-core detects the existing router context and uses it instead of creating its own. +```jsx +import * as React from 'react'; +import { createHashRouter, RouterProvider, Outlet, Link } from 'react-router'; +import { CoreAdmin, Resource } from 'ra-core'; +import { reactRouterNextProvider } from 'ra-router-react-router-next'; +import { dataProvider } from './dataProvider'; +import { PostList, PostEdit } from './posts'; + +const Layout = () => ( +
    + + +
    +); + +const StoreAdmin = () => ( + + + +); + +const router = createHashRouter([ + { + path: '/', + element: , + children: [ + { index: true, element:
    Welcome to my app!
    }, + // Mount ra-core at /admin. The splat matches /admin/posts, /admin/posts/1, etc. + { path: 'admin/*', element: }, + ], + }, +]); + +const App = () => ; + +export default App; +``` + +**Important**: When embedding ra-core, set the `basename` prop to match the path where ra-core is mounted. In the example above, ra-core is mounted at `/admin`, so `basename="/admin"`. + +### Links Under The Basename + +In embedded mode, `ra-router-react-router-next` adds the basename to the absolute paths you pass to ra-core's routing components and hooks (``, ``, `useNavigate`), unless the path already starts with the basename. This is the same behavior as the [TanStack Router adapter](./TanStackRouter.md). With `basename="/admin"`: + +```jsx +import { LinkBase } from 'ra-core'; + +// Points to /admin/posts/1/show +Post #1 +// Points to /admin/posts (already prefixed, left as is) +Posts +// Points to /admin/, not to the host app's home page +Home +``` + +To link to a route of the host application from inside ra-core, use React Router's own `` (`import { Link } from 'react-router'`), which doesn't add the basename. + +**Note:** The default `ra-router-react-router` adapter (React Router v6/v7) behaves differently: it leaves absolute paths untouched. Only the paths built by ra-core itself (with `useCreatePath`, redirections, and authentication redirects) include the basename, so `` points to `/posts/1/show` in the host application. If you migrate an embedded app from the default adapter, check the hand-written links that point outside ra-core, as they now point inside it. + ## When To Use This Package - Use the **default** `ra-router-react-router` adapter (installed automatically, no extra setup) if your app runs on React Router v6 or v7. From 2c6e14fef1991245d6303b081f8e7a08418f5339 Mon Sep 17 00:00:00 2001 From: Shaoyu Meng Date: Mon, 2 Nov 2026 17:01:00 -0800 Subject: [PATCH 64/67] docs: Align the headless router sentence with the react-admin Routing page The react-admin Routing page says the default router is react-router v6/v7 through the ra-router-react-router adapter, and links to the React-Router Configuration section. The headless page only said "react-router", which leaves out that v8 needs a different adapter. Co-Authored-By: Claude Opus 5.5 --- docs_headless/src/content/docs/Routing.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs_headless/src/content/docs/Routing.md b/docs_headless/src/content/docs/Routing.md index ff40ff46fbe..8458b6603ab 100644 --- a/docs_headless/src/content/docs/Routing.md +++ b/docs_headless/src/content/docs/Routing.md @@ -161,7 +161,7 @@ See [the troubleshooting section](#troubleshooting) for a solution. ## Using A Different Router Library -Ra-core 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) or [React Router v8](./ReactRouterV8.md) as an alternative. +Ra-core supports multiple routing libraries through its router abstraction layer. By default, it uses react-router (v6/v7) through the [`ra-router-react-router`](#react-router-configuration) 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: From 9f0121768d14ef4c48f2e439af4a7d5fa408657c Mon Sep 17 00:00:00 2001 From: Shaoyu Meng Date: Mon, 2 Nov 2026 19:56:38 -0800 Subject: [PATCH 65/67] chore: Drop the package-local intro comment from the react-router-next jest config The remaining comment already names the test-unit-react-router-next scripts and explains why this package needs its own jest config. Co-Authored-By: Claude Opus 5.5 --- packages/ra-router-react-router-next/jest.config.cjs | 4 ---- 1 file changed, 4 deletions(-) diff --git a/packages/ra-router-react-router-next/jest.config.cjs b/packages/ra-router-react-router-next/jest.config.cjs index a16e1b3f8f8..839a52bda47 100644 --- a/packages/ra-router-react-router-next/jest.config.cjs +++ b/packages/ra-router-react-router-next/jest.config.cjs @@ -1,10 +1,6 @@ const path = require('path'); const fs = require('fs'); -// Package-local jest config for ra-router-react-router-next. The root jest config -// ignores this package; it runs separately through the -// `test-unit-react-router-next` scripts. -// // 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 `test-unit-react-router-next` scripts pass `NODE_OPTIONS=--experimental-vm-modules`), From f4f659b9ffc272905f1321208b4b64c77af4c0b1 Mon Sep 17 00:00:00 2001 From: Shaoyu Meng Date: Mon, 2 Nov 2026 22:19:24 -0800 Subject: [PATCH 66/67] docs: Tighten the React Router v8 page and document basename links for both modes Move the bundle note before the installation steps and shorten it. Give standalone mode the same structure and example as the TanStack Router page, and move the basename rules into their own section, since they apply to both standalone and embedded mode. Co-Authored-By: Claude Opus 5.5 --- docs/ReactRouterV8.md | 46 ++++++++++++------- .../src/content/docs/ReactRouterV8.md | 46 ++++++++++++------- 2 files changed, 60 insertions(+), 32 deletions(-) diff --git a/docs/ReactRouterV8.md b/docs/ReactRouterV8.md index 72cc8e5b65f..4aa654379cf 100644 --- a/docs/ReactRouterV8.md +++ b/docs/ReactRouterV8.md @@ -9,7 +9,7 @@ By default, react-admin is powered by [React Router](https://reactrouter.com/) v **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. +**Note:** `ra-core` depends on the default `ra-router-react-router` adapter, which brings its own copy of `react-router` v7, so an application using `ra-router-react-router-next` bundles both React Router v7 and v8, even though it only uses v8. This will be fixed in react-admin v6, when `ra-router-react-router-next` becomes the default adapter. ## Installation @@ -21,8 +21,6 @@ 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`. -**Note:** For backward compatibility, React Router v6/v7 remains the default router, so `ra-core` always depends on the default `ra-router-react-router` adapter, which brings its own copy of `react-router` v7. As a consequence, an application using `ra-router-react-router-next` installs both React Router v7 and v8, and the v7 copy ends up in the application bundle. The application only uses React Router v8 at runtime, but the bundle is larger. This will be fixed in react-admin v6, when `ra-router-react-router-next` becomes the default adapter. - ## Configuration Set the `` to `reactRouterNextProvider`: @@ -49,9 +47,30 @@ That's it! React-admin will now use React Router v8 for all routing operations. ## Standalone Mode -When using `reactRouterNextProvider` without an existing React Router, react-admin creates its own hash router automatically (URLs like `/#/posts`). This is called **standalone mode**. This is the simplest setup and requires no additional configuration. +When using `reactRouterNextProvider` without an existing React Router, react-admin creates its own router automatically. This is called **standalone mode**. + +In standalone mode, react-admin: + +- Creates a React Router with hash-based history (URLs like `/#/posts`) +- Handles all route matching internally +- Manages navigation and history -If you set the `` prop in standalone mode, the hash router applies it to every route, so write paths without it (e.g. ``, not ``). +This is the simplest setup and requires no additional configuration. + +```tsx +// Standalone mode - react-admin creates the router +import { Admin, Resource } from 'react-admin'; +import { reactRouterNextProvider } from 'ra-router-react-router-next'; + +const App = () => ( + + + +); +``` ## Embedded Mode @@ -104,26 +123,21 @@ export default App; **Important**: When embedding react-admin, set the `basename` prop to match the path where react-admin is mounted. In the example above, react-admin is mounted at `/admin`, so `basename="/admin"`. -### Links Under The Basename +## Links Under The Basename -In embedded mode, `ra-router-react-router-next` adds the basename to the absolute paths you pass to react-admin's routing components and hooks (``, ``, ``, `useNavigate`), unless the path already starts with the basename. This is the same behavior as the [TanStack Router adapter](./TanStackRouter.md). With `basename="/admin"`: +`ra-router-react-router-next` adds the basename to the absolute paths you pass to react-admin's routing components and hooks (``, ``, ``, `useNavigate`), so write paths without it. This is the same behavior as the [TanStack Router adapter](./TanStackRouter.md). With `basename="/admin"`: ```tsx import { Link } from 'react-admin'; // Points to /admin/posts/1/show Post #1 -// Points to /admin/posts (already prefixed, left as is) -Posts -// Points to /admin/, not to the host app's home page +// Points to the react-admin home page, not to the host app's home page Home ``` -To link to a route of the host application from inside react-admin, use React Router's own `` (`import { Link } from 'react-router'`), which doesn't add the basename. - -**Note:** The default `ra-router-react-router` adapter (React Router v6/v7) behaves differently: it leaves absolute paths untouched. Only the paths built by react-admin itself (with `useCreatePath`, redirections, and authentication redirects) include the basename, so `` points to `/posts/1/show` in the host application. If you migrate an embedded admin from the default adapter, check the hand-written links that point outside react-admin, as they now point inside it. +In embedded mode, a path that already starts with the basename is left as is: `` points to `/admin/posts`. In standalone mode, the router adds the basename to every path, so the same link points to `/admin/admin/posts`. -## When To Use This Package +To link to a route of the host application from inside react-admin, use React Router's own `` (`import { Link } from 'react-router'`), which doesn't add the basename. -- 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. +**Note:** The default `ra-router-react-router` adapter (React Router v6/v7) behaves differently: it leaves absolute paths untouched. Only the paths built by react-admin itself (with `useCreatePath`, redirections, and authentication redirects) include the basename, so in embedded mode `` points to `/posts/1/show` in the host application. If you migrate an embedded admin from the default adapter, check the hand-written links that point outside react-admin, as they now point inside it. diff --git a/docs_headless/src/content/docs/ReactRouterV8.md b/docs_headless/src/content/docs/ReactRouterV8.md index a67cdd5d088..9b442dbeabf 100644 --- a/docs_headless/src/content/docs/ReactRouterV8.md +++ b/docs_headless/src/content/docs/ReactRouterV8.md @@ -8,7 +8,7 @@ By default, ra-core is powered by [React Router](https://reactrouter.com/) v6/v7 **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 ra-core v6.** -Use this package when your application runs on React Router v8. +**Note:** `ra-core` depends on the default `ra-router-react-router` adapter, which brings its own copy of `react-router` v7, so an application using `ra-router-react-router-next` bundles both React Router v7 and v8, even though it only uses v8. This will be fixed in ra-core v6, when `ra-router-react-router-next` becomes the default adapter. ## Installation @@ -20,8 +20,6 @@ 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`. -**Note:** For backward compatibility, React Router v6/v7 remains the default router, so `ra-core` always depends on the default `ra-router-react-router` adapter, which brings its own copy of `react-router` v7. As a consequence, an application using `ra-router-react-router-next` installs both React Router v7 and v8, and the v7 copy ends up in the application bundle. The application only uses React Router v8 at runtime, but the bundle is larger. This will be fixed in ra-core v6, when `ra-router-react-router-next` becomes the default adapter. - ## Configuration Set the `` to `reactRouterNextProvider`: @@ -54,9 +52,30 @@ That's it! Ra-core will now use React Router v8 for all routing operations. ## Standalone Mode -When using `reactRouterNextProvider` without an existing React Router, ra-core 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, ra-core creates its own router automatically. This is called **standalone mode**. + +In standalone mode, ra-core: + +- Creates a React Router with hash-based history (URLs like `/#/posts`) +- Handles all route matching internally +- Manages navigation and history -If you set the `` prop in standalone mode, the hash router applies it to every route, so write paths without it (e.g. ``, not ``). +This is the simplest setup and requires no additional configuration. + +```jsx +// Standalone mode - ra-core creates the router +import { CoreAdmin, Resource } from 'ra-core'; +import { reactRouterNextProvider } from 'ra-router-react-router-next'; + +const App = () => ( + + + +); +``` ## Embedded Mode @@ -109,26 +128,21 @@ export default App; **Important**: When embedding ra-core, set the `basename` prop to match the path where ra-core is mounted. In the example above, ra-core is mounted at `/admin`, so `basename="/admin"`. -### Links Under The Basename +## Links Under The Basename -In embedded mode, `ra-router-react-router-next` adds the basename to the absolute paths you pass to ra-core's routing components and hooks (``, ``, `useNavigate`), unless the path already starts with the basename. This is the same behavior as the [TanStack Router adapter](./TanStackRouter.md). With `basename="/admin"`: +`ra-router-react-router-next` adds the basename to the absolute paths you pass to ra-core's routing components and hooks (``, ``, `useNavigate`), so write paths without it. This is the same behavior as the [TanStack Router adapter](./TanStackRouter.md). With `basename="/admin"`: ```jsx import { LinkBase } from 'ra-core'; // Points to /admin/posts/1/show Post #1 -// Points to /admin/posts (already prefixed, left as is) -Posts -// Points to /admin/, not to the host app's home page +// Points to the ra-core home page, not to the host app's home page Home ``` -To link to a route of the host application from inside ra-core, use React Router's own `` (`import { Link } from 'react-router'`), which doesn't add the basename. - -**Note:** The default `ra-router-react-router` adapter (React Router v6/v7) behaves differently: it leaves absolute paths untouched. Only the paths built by ra-core itself (with `useCreatePath`, redirections, and authentication redirects) include the basename, so `` points to `/posts/1/show` in the host application. If you migrate an embedded app from the default adapter, check the hand-written links that point outside ra-core, as they now point inside it. +In embedded mode, a path that already starts with the basename is left as is: `` points to `/admin/posts`. In standalone mode, the router adds the basename to every path, so the same link points to `/admin/admin/posts`. -## When To Use This Package +To link to a route of the host application from inside ra-core, use React Router's own `` (`import { Link } from 'react-router'`), which doesn't add the basename. -- 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 ra-core v6. +**Note:** The default `ra-router-react-router` adapter (React Router v6/v7) behaves differently: it leaves absolute paths untouched. Only the paths built by ra-core itself (with `useCreatePath`, redirections, and authentication redirects) include the basename, so in embedded mode `` points to `/posts/1/show` in the host application. If you migrate an embedded app from the default adapter, check the hand-written links that point outside ra-core, as they now point inside it. From 418dc895b6ce7b0199c12b7ecbf7a10781f79ac8 Mon Sep 17 00:00:00 2001 From: Shaoyu Meng Date: Mon, 2 Nov 2026 22:47:17 -0800 Subject: [PATCH 67/67] fix: Make the react-router v8 adapter leave the basename to the router and ra-core The v8 adapter's Link, Navigate and useNavigate prepended the basename to hand-written absolute paths in embedded mode, like the TanStack adapter. The default v6/v7 adapter doesn't, so an embedded admin moving from v6/v7 to v8 would see links to host routes such as "/" start pointing inside the admin. Use the v6/v7 behavior instead, so the migration doesn't change where links point: the hash router applies the basename in standalone mode, and ra-core's useCreatePath, useRedirect and auth hooks apply it in embedded mode. The adapter now passes react-router's Link and Navigate through, and useNavigate only keeps a stable reference, as in the v6/v7 adapter. Port the stories and tests from the matching v6/v7 change: resource links in the stories use useCreatePath, and the spec covers standalone Link, useNavigate and Navigate with a basename, plus embedded useCreatePath and hand-written paths. Update the React Router v8 pages to describe this and point out that the TanStack adapter differs in embedded mode. Co-Authored-By: Claude Opus 5.5 --- docs/ReactRouterV8.md | 31 +-- .../src/content/docs/ReactRouterV8.md | 31 +-- .../src/reactRouterNextProvider.spec.tsx | 189 +++++++++++++++++- .../src/reactRouterNextProvider.stories.tsx | 79 +++++--- .../src/reactRouterNextProvider.tsx | 118 +---------- 5 files changed, 281 insertions(+), 167 deletions(-) diff --git a/docs/ReactRouterV8.md b/docs/ReactRouterV8.md index 4aa654379cf..f4356e2c8b6 100644 --- a/docs/ReactRouterV8.md +++ b/docs/ReactRouterV8.md @@ -125,19 +125,26 @@ export default App; ## Links Under The Basename -`ra-router-react-router-next` adds the basename to the absolute paths you pass to react-admin's routing components and hooks (``, ``, ``, `useNavigate`), so write paths without it. This is the same behavior as the [TanStack Router adapter](./TanStackRouter.md). With `basename="/admin"`: +In standalone mode, the router adds the basename to every path, so write paths without it. With `basename="/admin"`, `` points to `/admin/posts/1/show`. -```tsx -import { Link } from 'react-admin'; +In embedded mode, `ra-router-react-router-next` leaves absolute paths untouched, like the default `ra-router-react-router` adapter. Only the paths built by react-admin itself (with `useCreatePath`, redirections, and authentication redirects) include the basename. Use `useCreatePath` to link to react-admin pages: -// Points to /admin/posts/1/show -Post #1 -// Points to the react-admin home page, not to the host app's home page -Home +```tsx +import { Link, useCreatePath } from 'react-admin'; + +const Dashboard = () => { + const createPath = useCreatePath(); + return ( + <> + {/* Points to /admin/posts/1/show */} + + Post #1 + + {/* Points to the host app's home page */} + Home + + ); +}; ``` -In embedded mode, a path that already starts with the basename is left as is: `` points to `/admin/posts`. In standalone mode, the router adds the basename to every path, so the same link points to `/admin/admin/posts`. - -To link to a route of the host application from inside react-admin, use React Router's own `` (`import { Link } from 'react-router'`), which doesn't add the basename. - -**Note:** The default `ra-router-react-router` adapter (React Router v6/v7) behaves differently: it leaves absolute paths untouched. Only the paths built by react-admin itself (with `useCreatePath`, redirections, and authentication redirects) include the basename, so in embedded mode `` points to `/posts/1/show` in the host application. If you migrate an embedded admin from the default adapter, check the hand-written links that point outside react-admin, as they now point inside it. +**Note:** The [TanStack Router adapter](./TanStackRouter.md) behaves differently in embedded mode: it adds the basename to absolute paths that don't already start with it, so `` points to `/admin/posts/1/show`. diff --git a/docs_headless/src/content/docs/ReactRouterV8.md b/docs_headless/src/content/docs/ReactRouterV8.md index 9b442dbeabf..eb916cf4252 100644 --- a/docs_headless/src/content/docs/ReactRouterV8.md +++ b/docs_headless/src/content/docs/ReactRouterV8.md @@ -130,19 +130,26 @@ export default App; ## Links Under The Basename -`ra-router-react-router-next` adds the basename to the absolute paths you pass to ra-core's routing components and hooks (``, ``, `useNavigate`), so write paths without it. This is the same behavior as the [TanStack Router adapter](./TanStackRouter.md). With `basename="/admin"`: +In standalone mode, the router adds the basename to every path, so write paths without it. With `basename="/admin"`, `` points to `/admin/posts/1/show`. -```jsx -import { LinkBase } from 'ra-core'; +In embedded mode, `ra-router-react-router-next` leaves absolute paths untouched, like the default `ra-router-react-router` adapter. Only the paths built by ra-core itself (with `useCreatePath`, redirections, and authentication redirects) include the basename. Use `useCreatePath` to link to ra-core pages: -// Points to /admin/posts/1/show -Post #1 -// Points to the ra-core home page, not to the host app's home page -Home +```jsx +import { LinkBase, useCreatePath } from 'ra-core'; + +const Dashboard = () => { + const createPath = useCreatePath(); + return ( + <> + {/* Points to /admin/posts/1/show */} + + Post #1 + + {/* Points to the host app's home page */} + Home + + ); +}; ``` -In embedded mode, a path that already starts with the basename is left as is: `` points to `/admin/posts`. In standalone mode, the router adds the basename to every path, so the same link points to `/admin/admin/posts`. - -To link to a route of the host application from inside ra-core, use React Router's own `` (`import { Link } from 'react-router'`), which doesn't add the basename. - -**Note:** The default `ra-router-react-router` adapter (React Router v6/v7) behaves differently: it leaves absolute paths untouched. Only the paths built by ra-core itself (with `useCreatePath`, redirections, and authentication redirects) include the basename, so in embedded mode `` points to `/posts/1/show` in the host application. If you migrate an embedded app from the default adapter, check the hand-written links that point outside ra-core, as they now point inside it. +**Note:** The [TanStack Router adapter](./TanStackRouter.md) behaves differently in embedded mode: it adds the basename to absolute paths that don't already start with it, so `` points to `/admin/posts/1/show`. 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 c1b7f9d0d42..590163c74fd 100644 --- a/packages/ra-router-react-router-next/src/reactRouterNextProvider.spec.tsx +++ b/packages/ra-router-react-router-next/src/reactRouterNextProvider.spec.tsx @@ -7,6 +7,16 @@ import { cleanup, } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; +import fakeDataProvider from 'ra-data-fakerest'; +import { RouterProvider, createHashRouter } from 'react-router'; +import { + CoreAdmin, + LinkBase, + ListBase, + Resource, + useCreatePath, + useNavigate, +} from 'ra-core'; import { Basic, EmbeddedInReactRouter, @@ -31,7 +41,7 @@ import { } from './reactRouterNextProvider.stories'; import { reactRouterNextProvider } from './reactRouterNextProvider'; -const { matchPath } = reactRouterNextProvider; +const { matchPath, Navigate } = reactRouterNextProvider; describe('reactRouterNextProvider', () => { // Reset hash before each test to ensure clean state @@ -540,9 +550,186 @@ describe('reactRouterNextProvider', () => { ).toBeInTheDocument(); }); }); + + describe('with a basename', () => { + // The hash router carries the basename, so links and + // navigation must apply it exactly once. + const dataProvider = fakeDataProvider({ + posts: [{ id: 1, title: 'Post #1' }], + }); + + const PostList = () => { + const navigate = useNavigate(); + const [redirect, setRedirect] = React.useState(false); + return ( + ( + <> + {data?.map(record => ( + + {record.title} + + ))} + + + {redirect && ( + + )} + + )} + /> + ); + }; + + const PostShow = () =>

    Post Details

    ; + + const StandaloneWithBasename = () => ( + + + + ); + + beforeEach(() => { + window.location.hash = '#/admin/posts'; + }); + + it('should apply the basename once to Link', async () => { + render(); + const link = await screen.findByText('Post #1'); + expect(link.getAttribute('href')).toBe( + '#/admin/posts/1/show' + ); + }); + + it('should apply the basename once to useNavigate', async () => { + render(); + fireEvent.click( + await screen.findByText('Navigate to Post #1') + ); + await waitFor(() => { + expect(window.location.hash).toBe( + '#/admin/posts/1/show' + ); + }); + await screen.findByText('Post Details'); + }); + + it('should apply the basename once to Navigate', async () => { + render(); + fireEvent.click( + await screen.findByText('Redirect to Post #1') + ); + await waitFor(() => { + expect(window.location.hash).toBe( + '#/admin/posts/1/show' + ); + }); + await screen.findByText('Post Details'); + }); + }); }); describe('embedded mode', () => { + describe('with a basename', () => { + // As in upstream react-admin, ra-core applies the basename + // (useCreatePath, useRedirect, auth hooks); the adapter + // leaves hand-written absolute paths untouched. + const dataProvider = fakeDataProvider({ + posts: [{ id: 1, title: 'Post #1' }], + }); + + const PostList = () => { + const createPath = useCreatePath(); + return ( + ( + <> + {data?.map(record => ( + + {record.title} + + ))} + Back to host + + )} + /> + ); + }; + + const EmbeddedWithBasename = () => { + const router = React.useMemo( + () => + createHashRouter([ + { + path: '/admin/*', + element: ( + + + + ), + }, + ]), + [] + ); + return ; + }; + + beforeEach(() => { + window.location.hash = '#/admin/posts'; + }); + + it('should apply the basename once to paths from useCreatePath', async () => { + render(); + const link = await screen.findByText('Post #1'); + expect(link.getAttribute('href')).toBe( + '#/admin/posts/1/show' + ); + }); + + it('should not apply the basename to hand-written absolute paths', async () => { + render(); + const link = await screen.findByText('Back to host'); + expect(link.getAttribute('href')).toBe('#/'); + }); + }); + it('should render home page initially', async () => { render(); await waitFor(() => { 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..48fcc1f55ee 100644 --- a/packages/ra-router-react-router-next/src/reactRouterNextProvider.stories.tsx +++ b/packages/ra-router-react-router-next/src/reactRouterNextProvider.stories.tsx @@ -16,6 +16,7 @@ import { EditBase, CreateBase, useRecordContext, + useCreatePath, CoreAdmin, Resource, CustomRoutes, @@ -55,38 +56,56 @@ const dataProvider = fakeDataProvider( process.env.NODE_ENV === 'development' ); -const PostList = () => ( - -
    -

    Posts

    - ( - - {record.title} - - )} - /> - -
    -
    -); - -const PostShow = () => ( - ( +// Links use useCreatePath so that they carry the basename when react-admin +// is embedded under a sub path (see EmbeddedInReactRouter) +const PostList = () => { + const createPath = useCreatePath(); + return ( +
    -

    Post Details

    - - ID: {record?.id} - Title: {record?.title} - Body: {record?.body} - - Back to list +

    Posts

    + ( + + {record.title} + + )} + /> +
    - )} - /> -); +
    + ); +}; + +const PostShow = () => { + const createPath = useCreatePath(); + return ( + ( +
    +

    Post Details

    + + ID: {record?.id} + Title: {record?.title} + Body: {record?.body} + + + Back to list + +
    + )} + /> + ); +}; const PostEdit = () => ( diff --git a/packages/ra-router-react-router-next/src/reactRouterNextProvider.tsx b/packages/ra-router-react-router-next/src/reactRouterNextProvider.tsx index b4654385ac6..82f30a9d2e7 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, forwardRef, ReactNode } from 'react'; +import { useContext, useEffect, useRef, ReactNode } from 'react'; import { useNavigate as useReactRouterNavigate, useLocation, @@ -7,8 +7,8 @@ import { useBlocker, useMatch, useInRouterContext, - Link as ReactRouterLink, - Navigate as ReactRouterNavigate, + Link, + Navigate, Route, Routes, Outlet, @@ -18,13 +18,10 @@ import { UNSAFE_DataRouterContext, UNSAFE_DataRouterStateContext, } from 'react-router'; -import { useBasename } from 'ra-core'; import type { RouterProvider, RouterWrapperProps, RouterNavigateFunction, - RouterLinkProps, - RouterNavigateProps, } from 'ra-core'; /** @@ -48,7 +45,6 @@ const useCanBlock = (): boolean => { */ const useNavigate = (): RouterNavigateFunction => { const navigate = useReactRouterNavigate(); - const basename = useBasename(); const navigateRef = useRef( navigate as RouterNavigateFunction ); @@ -58,111 +54,9 @@ const useNavigate = (): RouterNavigateFunction => { }, [navigate]); // Return a stable function that always calls the latest navigate - return React.useCallback( - (...args: Parameters) => { - const [to, ...rest] = args; - - // Handle numeric navigation (go back/forward) - if (typeof to === 'number') { - return navigateRef.current(to, ...rest); - } - - // Helper to prepend basename to absolute paths - // Only prepend if path doesn't already start with basename - const resolvePath = (path: string) => { - if (!basename || !path.startsWith('/')) return path; - // Don't prepend if path already includes basename - if (path.startsWith(basename + '/') || path === basename) { - return path; - } - return `${basename}${path}`; - }; - - // Handle object navigation { pathname?, search?, hash?, state? } - // This covers both { pathname: '/foo' } and { search: '?bar=1' } - if (typeof to === 'object' && to !== null) { - // If no pathname provided, keep current pathname - return navigateRef.current( - to.pathname - ? { ...to, pathname: resolvePath(to.pathname) } - : to, - ...rest - ); - } - - // Handle string path - const resolvedPath = resolvePath(to as string); - return navigateRef.current(resolvedPath, ...rest); - }, - [basename] - ) as RouterNavigateFunction; -}; - -const Link = forwardRef( - ({ to, ...rest }, ref) => { - const basename = useBasename(); - - // Helper to prepend basename to absolute paths - const resolvePath = (path: string) => { - if (!basename || !path.startsWith('/')) return path; - if (path.startsWith(basename + '/') || path === basename) { - return path; - } - return `${basename}${path}`; - }; - - // Handle object `to` (e.g., { pathname: '/path', search: '?foo=bar' }) - let resolvedTo: typeof to; - if (typeof to === 'object' && to !== null) { - // If no pathname provided, keep it as-is to stay on current page - resolvedTo = to.pathname - ? { ...to, pathname: resolvePath(to.pathname) } - : to; - } else { - resolvedTo = resolvePath(to as string); - } - return ; - } -); -Link.displayName = 'Link'; - -const Navigate = ({ to, ...rest }: RouterNavigateProps) => { - const basename = useBasename(); - const currentLocation = useLocation(); - - // Handle both string and object forms of `to` - let resolvedPath: string; - - if (typeof to === 'string') { - resolvedPath = to; - } else { - // If no pathname provided, use current pathname to stay on current page - resolvedPath = to.pathname ?? currentLocation.pathname; - - // Append search and hash directly to the path to preserve the raw - // query string format - if (to.search) { - resolvedPath += to.search.startsWith('?') - ? to.search - : `?${to.search}`; - } - if (to.hash) { - resolvedPath += to.hash.startsWith('#') ? to.hash : `#${to.hash}`; - } - } - - // Prepend basename to the path - // Only prepend if path doesn't already start with basename - if (basename && resolvedPath.startsWith('/')) { - if ( - !resolvedPath.startsWith(basename + '/') && - resolvedPath !== basename - ) { - resolvedPath = `${basename}${resolvedPath}`; - } - } - - return ; + return React.useCallback((...args: Parameters) => { + return navigateRef.current(...args); + }, []) as RouterNavigateFunction; }; /**