Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Upgrade react-router to 6.22.0, use data router, stabilize useWarnWhenUnsavedChanges and remove <Admin history> prop #9657

Merged
merged 33 commits into from
Feb 29, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
536096c
[no ci] wip: upgrade react-router and rewrite useWarnWhenUnsavedChanges
slax57 Feb 9, 2024
7ccead7
handle closing tab with beforeunload
slax57 Feb 16, 2024
028765a
update tests
slax57 Feb 16, 2024
6bc0f39
[no ci] use createHashRouter in AdminRouter and remove history prop
slax57 Feb 16, 2024
441a22a
fix use element instead of Component
slax57 Feb 21, 2024
51ec845
[no ci] wip: introduce TestMemoryRouter
slax57 Feb 21, 2024
2268b01
[no ci] fix should allow custom redirect with warnWhenUnsavedChanges …
slax57 Feb 22, 2024
821e6c7
[no ci] migrate 2 more files and add support for navigateCallback
slax57 Feb 23, 2024
3d58967
[no ci] update TabbedShowLayout tests
slax57 Feb 23, 2024
0892754
[no ci] [wip] codemod 1st version!
slax57 Feb 26, 2024
cd69510
[no ci] [wip] preserve initialEntries
slax57 Feb 26, 2024
c3efa4d
[no ci] [wip] add replace-MemoryRouter codemod
slax57 Feb 26, 2024
de05871
[no ci] [wip] run replace-Admin-history on ra-core
slax57 Feb 27, 2024
fa13590
[no ci] [wip] run replace-MemoryRouter on ra-core
slax57 Feb 27, 2024
91b81e6
[no ci] update FakeBrowser jsdoc
slax57 Feb 27, 2024
e20413a
[no ci] clean up last call to history in ra-core
slax57 Feb 27, 2024
133f13f
[no ci] run replace-Admin-history in ra-ui-materialui
slax57 Feb 27, 2024
be7a8c7
[no ci] run replace-MemoryRouter in ra-ui-materialui
slax57 Feb 27, 2024
353968b
fix most tests
slax57 Feb 27, 2024
3c13fc8
fix tests about rendering error
slax57 Feb 27, 2024
aebc600
add codemods to ra-core package
slax57 Feb 27, 2024
b976c1f
fix FakeBrowser BrowserBar location
slax57 Feb 27, 2024
cf45a6e
fix stories
slax57 Feb 27, 2024
b81a73a
remove dep on history
slax57 Feb 27, 2024
eec30bc
cleanup test code
slax57 Feb 27, 2024
2165633
Merge remote-tracking branch 'origin/next' into react-router-6.22.0
slax57 Feb 27, 2024
3a45bab
upgrade guide
slax57 Feb 27, 2024
513c4b1
code review 1
slax57 Feb 27, 2024
a5c6dbc
fix tests after merge
slax57 Feb 27, 2024
43abae5
code review
slax57 Feb 29, 2024
d973d48
code review 2
slax57 Feb 29, 2024
289843a
code review 3
slax57 Feb 29, 2024
2eb491a
fix blocker is not stable
slax57 Feb 29, 2024
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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/ \
fzaninotto marked this conversation as resolved.
Show resolved Hide resolved
--extensions=js,jsx \
--transform=./node_modules/ra-core/codemods/replace-Admin-history.ts
fzaninotto marked this conversation as resolved.
Show resolved Hide resolved
```

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
Loading