Skip to content

Commit

Permalink
Merge pull request #9657 from marmelab/react-router-6.22.0
Browse files Browse the repository at this point in the history
Upgrade `react-router` to 6.22.0, use data router, stabilize `useWarnWhenUnsavedChanges` and remove `<Admin history>` prop
  • Loading branch information
fzaninotto authored Feb 29, 2024
2 parents e444030 + 2eb491a commit 0d75c41
Show file tree
Hide file tree
Showing 114 changed files with 8,353 additions and 2,891 deletions.
136 changes: 136 additions & 0 deletions docs/Upgrade.md
Original file line number Diff line number Diff line change
Expand Up @@ -482,6 +482,142 @@ import { FieldProps, useRecordContext } from 'react-admin';
}
```

## `useWarnWhenUnsavedChanges` Requires A Data Router

The `useWarnWhenUnsavedChanges` hook was reimplemented using [`useBlocker`](https://reactrouter.com/en/main/hooks/use-blocker) from `react-router`. As a consequence, it now requires a [data router](https://reactrouter.com/en/main/routers/picking-a-router) to be used.

The `<Admin>` component has been updated to use [`createHashRouter`](https://reactrouter.com/en/main/routers/create-hash-router) internally by default, which is a data router. So you don't need to change anything if you are using `react-admin`'s internal router.

If you are using an external router, you will need to migrate it to a data router to be able to use the `warnWhenUnsavedChanges` feature.

```diff
import * as React from 'react';
import { Admin, Resource } from 'react-admin';
import { createRoot } from 'react-dom/client';
-import { BrowserRouter } from 'react-router-dom';
+import { createBrowserRouter, RouterProvider } from 'react-router-dom';

import dataProvider from './dataProvider';
import posts from './posts';

const App = () => (
- <BrowserRouter>
<Admin dataProvider={dataProvider}>
<Resource name="posts" {...posts} />
</Admin>
- </BrowserRouter>
);

+const router = createBrowserRouter([{ path: '*', element: <App /> }]);

const container = document.getElementById('root');
const root = createRoot(container);

root.render(
<React.StrictMode>
- <App />
+ <RouterProvider router={router} />
</React.StrictMode>
);
```

### Minor Changes

Due to the new implementation using `useBlocker`, you may also notice the following minor changes:

- `useWarnWhenUnsavedChanges` will also open a confirmation dialog (and block the navigation) if a navigation is fired when the form is currently submitting (submission will continue in the background).
- [Due to browser constraints](https://stackoverflow.com/questions/38879742/is-it-possible-to-display-a-custom-message-in-the-beforeunload-popup), the message displayed in the confirmation dialog when closing the browser's tab cannot be customized (it is managed by the browser).

## `<Admin history>` Prop Was Removed

The `<Admin history>` prop was deprecated since version 4. It is no longer supported.

The most common use-case for this prop was inside unit tests (and stories), to pass a `MemoryRouter` and control the `initialEntries`.

To that purpose, `react-admin` now exports a `TestMemoryHistory` component that you can use in your tests:

```diff
import { render, screen } from '@testing-library/react';
-import { createMemoryHistory } from 'history';
-import { CoreAdminContext } from 'react-admin';
+import { CoreAdminContext, TestMemoryRouter } from 'react-admin';
import * as React from 'react';

describe('my test suite', () => {
it('my test', async () => {
- const history = createMemoryHistory({ initialEntries: ['/'] });
render(
+ <TestMemoryRouter initialEntries={['/']}>
- <CoreAdminContext history={history}>
+ <CoreAdminContext>
<div>My Component</div>
</CoreAdminContext>
+ </TestMemoryRouter>
);
await screen.findByText('My Component');
});
});
```

### Codemod

To help you migrate your tests, we've created a codemod that will replace the `<Admin history>` prop with the `<TestMemoryRouter>` component.

> **DISCLAIMER**
>
> This codemod was used to migrate the react-admin test suite, but it was never designed to cover all cases, and was not tested against other code bases. You can try using it as basis to see if it helps migrating your code base, but please review the generated changes thoroughly!
>
> Applying the codemod might break your code formatting, so please don't forget to run `prettier` and/or `eslint` after you've applied the codemod!
For `.js` or `.jsx` files:

```sh
npx jscodeshift ./path/to/src/ \
--extensions=js,jsx \
--transform=./node_modules/ra-core/codemods/replace-Admin-history.ts
```

For `.ts` or `.tsx` files:

```sh
npx jscodeshift ./path/to/src/ \
--extensions=ts,tsx \
--parser=tsx \
--transform=./node_modules/ra-core/codemods/replace-Admin-history.ts
```

## `<HistoryRouter>` Was Removed

Along with the removal of the `<Admin history>` prop, we also removed the (undocumented) `<HistoryRouter>` component.

Just like for `<Admin history>`, the most common use-case for this component was inside unit tests (and stories), to control the `initialEntries`.

Here too, you can use `TestMemoryHistory` as a replacement:

```diff
import { render, screen } from '@testing-library/react';
-import { createMemoryHistory } from 'history';
-import { CoreAdminContext, HistoryRouter } from 'react-admin';
+import { CoreAdminContext, TestMemoryRouter } from 'react-admin';
import * as React from 'react';

describe('my test suite', () => {
it('my test', async () => {
- const history = createMemoryHistory({ initialEntries: ['/'] });
render(
- <HistoryRouter history={history}>
+ <TestMemoryRouter initialEntries={['/']}>
<CoreAdminContext>
<div>My Component</div>
</CoreAdminContext>
- </HistoryRouter>
+ </TestMemoryRouter>
);
await screen.findByText('My Component');
});
});
```

## Upgrading to v4

If you are on react-admin v3, follow the [Upgrading to v4](https://marmelab.com/react-admin/doc/4.16/Upgrade.html) guide before upgrading to v5.
4 changes: 2 additions & 2 deletions examples/crm/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,8 @@
"react-admin": "^4.12.0",
"react-dom": "^18.2.0",
"react-error-boundary": "^3.1.4",
"react-router": "^6.1.0",
"react-router-dom": "^6.1.0"
"react-router": "^6.22.0",
"react-router-dom": "^6.22.0"
},
"devDependencies": {
"@testing-library/jest-dom": "^6.1.4",
Expand Down
4 changes: 2 additions & 2 deletions examples/demo/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -32,8 +32,8 @@
"react": "^18.0.0",
"react-admin": "^4.12.0",
"react-dom": "^18.2.0",
"react-router": "^6.1.0",
"react-router-dom": "^6.1.0",
"react-router": "^6.22.0",
"react-router-dom": "^6.22.0",
"recharts": "^2.1.15"
},
"scripts": {
Expand Down
4 changes: 2 additions & 2 deletions examples/simple/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -28,8 +28,8 @@
"react-admin": "^4.16.11",
"react-dom": "^18.2.0",
"react-hook-form": "^7.43.9",
"react-router": "^6.1.0",
"react-router-dom": "^6.1.0"
"react-router": "^6.22.0",
"react-router-dom": "^6.22.0"
},
"devDependencies": {
"@hookform/devtools": "^4.0.2",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
import * as React from 'react';
import expect from 'expect';
import { render, screen, waitFor } from '@testing-library/react';
import { createMemoryHistory } from 'history';
import { Routes, Route, useLocation } from 'react-router-dom';

// @ts-ignore
import { memoryStore } from '../store';
// @ts-ignore
import { CoreAdminContext } from '../core';
// @ts-ignore
import { useNotificationContext } from '../notification';
// @ts-ignore
import { Authenticated } from './Authenticated';

describe('<Authenticated>', () => {
const Foo = () => <div>Foo</div>;

it('should render its child by default', async () => {
const authProvider = {
login: () => Promise.reject('bad method'),
logout: () => Promise.reject('bad method'),
checkAuth: jest.fn().mockResolvedValueOnce(''),
checkError: () => Promise.reject('bad method'),
getPermissions: () => Promise.reject('bad method'),
};
const store = memoryStore();
const reset = jest.spyOn(store, 'reset');

render(
<CoreAdminContext authProvider={authProvider} store={store}>
<Authenticated>
<Foo />
</Authenticated>
</CoreAdminContext>
);
expect(screen.queryByText('Foo')).not.toBeNull();
expect(reset).toHaveBeenCalledTimes(0);
});

it('should logout, redirect to login and show a notification after a tick if the auth fails', async () => {
const authProvider = {
login: jest.fn().mockResolvedValue(''),
logout: jest.fn().mockResolvedValue(''),
checkAuth: jest.fn().mockRejectedValue(undefined),
checkError: jest.fn().mockResolvedValue(''),
getPermissions: jest.fn().mockResolvedValue(''),
};
const store = memoryStore();
const reset = jest.spyOn(store, 'reset');
const history = createMemoryHistory();

const Login = () => {
const location = useLocation();
return (
<div aria-label="nextPathname">
{(location.state as any).nextPathname}
</div>
);
};

let notificationsSpy;
const Notification = () => {
const { notifications } = useNotificationContext();
React.useEffect(() => {
notificationsSpy = notifications;
}, [notifications]);
return null;
};

render(
<CoreAdminContext
authProvider={authProvider}
store={store}
history={history}
>
<Notification />
<Routes>
<Route
path="/"
element={
<Authenticated>
<Foo />
</Authenticated>
}
/>
<Route path="/login" element={<Login />} />
</Routes>
</CoreAdminContext>
);
await waitFor(() => {
expect(authProvider.checkAuth.mock.calls[0][0]).toEqual({});
expect(authProvider.logout.mock.calls[0][0]).toEqual({});
expect(reset).toHaveBeenCalledTimes(1);
expect(notificationsSpy).toEqual([
{
message: 'ra.auth.auth_check_error',
type: 'error',
notificationOptions: {},
},
]);
expect(screen.getByLabelText('nextPathname').innerHTML).toEqual(
'/'
);
});
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
import * as React from 'react';
import expect from 'expect';
import { render, screen, waitFor } from '@testing-library/react';
import { TestMemoryRouter } from 'react-admin';
import { Routes, Route, useLocation } from 'react-router-dom';

// @ts-ignore
import { memoryStore } from '../store';
// @ts-ignore
import { CoreAdminContext } from '../core';
// @ts-ignore
import { useNotificationContext } from '../notification';
// @ts-ignore
import { Authenticated } from './Authenticated';

describe('<Authenticated>', () => {
const Foo = () => <div>Foo</div>;

it('should render its child by default', async () => {
const authProvider = {
login: () => Promise.reject('bad method'),
logout: () => Promise.reject('bad method'),
checkAuth: jest.fn().mockResolvedValueOnce(''),
checkError: () => Promise.reject('bad method'),
getPermissions: () => Promise.reject('bad method'),
};
const store = memoryStore();
const reset = jest.spyOn(store, 'reset');

render(
<CoreAdminContext authProvider={authProvider} store={store}>
<Authenticated>
<Foo />
</Authenticated>
</CoreAdminContext>
);
expect(screen.queryByText('Foo')).not.toBeNull();
expect(reset).toHaveBeenCalledTimes(0);
});

it('should logout, redirect to login and show a notification after a tick if the auth fails', async () => {
const authProvider = {
login: jest.fn().mockResolvedValue(''),
logout: jest.fn().mockResolvedValue(''),
checkAuth: jest.fn().mockRejectedValue(undefined),
checkError: jest.fn().mockResolvedValue(''),
getPermissions: jest.fn().mockResolvedValue(''),
};
const store = memoryStore();
const reset = jest.spyOn(store, 'reset');

const Login = () => {
const location = useLocation();
return (
<div aria-label="nextPathname">
{(location.state as any).nextPathname}
</div>
);
};

let notificationsSpy;
const Notification = () => {
const { notifications } = useNotificationContext();
React.useEffect(() => {
notificationsSpy = notifications;
}, [notifications]);
return null;
};

render(
<TestMemoryRouter>
<CoreAdminContext authProvider={authProvider} store={store}>
<Notification />
<Routes>
<Route
path="/"
element={
<Authenticated>
<Foo />
</Authenticated>
}
/>
<Route path="/login" element={<Login />} />
</Routes>
</CoreAdminContext>
</TestMemoryRouter>
);
await waitFor(() => {
expect(authProvider.checkAuth.mock.calls[0][0]).toEqual({});
expect(authProvider.logout.mock.calls[0][0]).toEqual({});
expect(reset).toHaveBeenCalledTimes(1);
expect(notificationsSpy).toEqual([
{
message: 'ra.auth.auth_check_error',
type: 'error',
notificationOptions: {},
},
]);
expect(screen.getByLabelText('nextPathname').innerHTML).toEqual(
'/'
);
});
});
});
Loading

0 comments on commit 0d75c41

Please sign in to comment.