From 4b1582b3c6c2a6be858b8a9538fe095487f6559a Mon Sep 17 00:00:00 2001 From: quentin-decre Date: Wed, 22 Jul 2026 16:41:06 +0200 Subject: [PATCH 1/7] Fix RichTextInput crash when the editor is recreated Tiptap v3's Editor.destroy() sets commandManager to null and the commands getter dereferences it. When the editor is recreated (readOnly/disabled/ editorOptions/id change), the content-sync passive effect could run against the just-destroyed instance and throw "Cannot read properties of null (reading 'commands')". Skip destroyed editors in addition to null ones. --- packages/ra-input-rich-text/src/RichTextInput.tsx | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/packages/ra-input-rich-text/src/RichTextInput.tsx b/packages/ra-input-rich-text/src/RichTextInput.tsx index 3688d686274..9b7b1c9a3c9 100644 --- a/packages/ra-input-rich-text/src/RichTextInput.tsx +++ b/packages/ra-input-rich-text/src/RichTextInput.tsx @@ -114,7 +114,12 @@ export const RichTextInput = (props: RichTextInputProps) => { const { error, invalid, isTouched } = fieldState; useEffect(() => { - if (!editor) return; + // Tiptap v3's Editor.destroy() sets commandManager to null, and the + // `commands` getter dereferences it. When the editor is recreated (e.g. + // readOnly/disabled/editorOptions/id change), this passive effect can run + // against the just-destroyed instance, throwing "Cannot read properties of + // null (reading 'commands')". Bail out on destroyed editors as well. + if (!editor || editor.isDestroyed) return; const { from, to } = editor.state.selection; From 938d9fb2656671ae4c08acba24b1c80a8d45e55e Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 4 Aug 2026 00:30:12 +0000 Subject: [PATCH 2/7] Bump ip-address from 10.2.0 to 10.4.0 Bumps [ip-address](https://github.com/beaugunderson/ip-address) from 10.2.0 to 10.4.0. - [Release notes](https://github.com/beaugunderson/ip-address/releases) - [Commits](https://github.com/beaugunderson/ip-address/compare/v10.2.0...v10.4.0) --- updated-dependencies: - dependency-name: ip-address dependency-version: 10.4.0 dependency-type: indirect ... Signed-off-by: dependabot[bot] --- yarn.lock | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/yarn.lock b/yarn.lock index 5f8e22c7e0d..37267acd1f3 100644 --- a/yarn.lock +++ b/yarn.lock @@ -14465,9 +14465,9 @@ __metadata: linkType: hard "ip-address@npm:^10.0.1": - version: 10.2.0 - resolution: "ip-address@npm:10.2.0" - checksum: 5a00aada6e922c9c69dfc800ed5d0fa3348675ebdeed0e1575f503f27ca385b5f534363c9af7ad1daf64c1f1409388cdd3cc2e9b9b0fe1c924a431378d55075a + version: 10.4.0 + resolution: "ip-address@npm:10.4.0" + checksum: d7b0bd2624fd861afbae6e49036a9b56f9506eaff7ff38592b7b4492dd5c272b53f5356dc8cc019e318acf29a96c90a3d1af1df761960267d23efa96858270fa languageName: node linkType: hard From 62a56d8349c2a36695080a2af8714b4a568ef87f Mon Sep 17 00:00:00 2001 From: weixiaoing <1537476031@qq.com> Date: Sat, 22 Aug 2026 20:08:54 +0800 Subject: [PATCH 3/7] fix: prevent duplicate undoable mutation commits --- .../src/layout/Notification.spec.tsx | 78 +++++++++++++++++++ .../src/layout/Notification.tsx | 8 +- 2 files changed, 84 insertions(+), 2 deletions(-) diff --git a/packages/ra-ui-materialui/src/layout/Notification.spec.tsx b/packages/ra-ui-materialui/src/layout/Notification.spec.tsx index e92d3686e26..97db3a093e6 100644 --- a/packages/ra-ui-materialui/src/layout/Notification.spec.tsx +++ b/packages/ra-ui-materialui/src/layout/Notification.spec.tsx @@ -1,6 +1,56 @@ import * as React from 'react'; import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +let mockMuiMajor = 5; + +jest.mock('@mui/material', () => { + const actual = jest.requireActual('@mui/material'); + const ReactModule = jest.requireActual('react') as typeof React; + const Mui6Snackbar = ReactModule.forwardRef((props, ref) => { + const wasOpen = ReactModule.useRef(props.open); + + ReactModule.useEffect(() => { + if (wasOpen.current && !props.open) { + props.slotProps?.transition?.onExited?.(null); + props.TransitionProps?.onExited?.(null); + } + wasOpen.current = props.open; + }, [props.open, props.slotProps, props.TransitionProps]); + + ReactModule.useEffect(() => { + if (!props.open) return; + + document.addEventListener('click', props.onClose); + return () => document.removeEventListener('click', props.onClose); + }, [props.open, props.onClose]); + + if (!props.open) return null; + + return ReactModule.createElement( + 'div', + { ref }, + props.children ?? props.message, + props.action, + ReactModule.createElement( + 'button', + { onClick: props.onClose }, + 'Close notification' + ) + ); + }); + const Snackbar = ReactModule.forwardRef((props, ref) => + mockMuiMajor >= 6 + ? ReactModule.createElement(Mui6Snackbar, { ...props, ref }) + : ReactModule.createElement(actual.Snackbar, { ...props, ref }) + ); + + return { + ...actual, + major: { valueOf: () => mockMuiMajor }, + Snackbar, + }; +}); + import { ConsecutiveNotifications, ConsecutiveUndoable, @@ -8,6 +58,34 @@ import { } from './Notification.stories'; describe('', () => { + afterEach(() => { + mockMuiMajor = 5; + }); + + it.each([6, 7, 9])( + 'should confirm an undoable mutation once when the notification exits with MUI %s', + async muiMajor => { + mockMuiMajor = muiMajor; + const deleteOne = jest + .fn() + .mockImplementation((_resource, { id }) => + Promise.resolve({ data: { id } }) + ); + const dataProvider = { delete: deleteOne } as any; + render(); + + (await screen.findByText('Delete post 1')).click(); + await screen.findByText('Post 1 deleted'); + + fireEvent.click( + screen.getByRole('button', { name: 'Close notification' }) + ); + + await waitFor(() => expect(deleteOne).toHaveBeenCalled()); + expect(deleteOne).toHaveBeenCalledTimes(1); + } + ); + it('should confirm the first undoable notification when a second one starts', async () => { const deleteOne = jest .fn() diff --git a/packages/ra-ui-materialui/src/layout/Notification.tsx b/packages/ra-ui-materialui/src/layout/Notification.tsx index 327bd1a1434..a7ab8d9409d 100644 --- a/packages/ra-ui-materialui/src/layout/Notification.tsx +++ b/packages/ra-ui-materialui/src/layout/Notification.tsx @@ -168,8 +168,12 @@ export const Notification = (inProps: NotificationProps) => { : autoHideDurationFromMessage ?? undefined } disableWindowBlurListener={undoable} - TransitionProps={transitionProps} - ContentProps={contentProps} + {...(muiMajor < 6 + ? { + TransitionProps: transitionProps, + ContentProps: contentProps, + } + : {})} onClose={handleRequestClose} action={ undoable ? ( From 470c35e350236ef7740c8d816cc603218e01b98c Mon Sep 17 00:00:00 2001 From: lprnmns Date: Mon, 31 Aug 2026 22:57:38 +0300 Subject: [PATCH 4/7] fix(datatable): skip delete access check when bulk actions are disabled --- .../src/list/datatable/DataTable.spec.tsx | 40 +++++++++++++++++++ .../src/list/datatable/DataTable.tsx | 1 + 2 files changed, 41 insertions(+) diff --git a/packages/ra-ui-materialui/src/list/datatable/DataTable.spec.tsx b/packages/ra-ui-materialui/src/list/datatable/DataTable.spec.tsx index 0277f304871..16d94b3cea7 100644 --- a/packages/ra-ui-materialui/src/list/datatable/DataTable.spec.tsx +++ b/packages/ra-ui-materialui/src/list/datatable/DataTable.spec.tsx @@ -1,5 +1,10 @@ import { fireEvent, render, screen, waitFor } from '@testing-library/react'; import * as React from 'react'; +import { + ResourceContextProvider, + TestMemoryRouter, + type AuthProvider, +} from 'ra-core'; import { Basic, Columns, @@ -12,6 +17,8 @@ import { StandaloneDynamic, StandaloneStatic, } from './DataTable.stories'; +import { DataTable } from './DataTable'; +import { AdminContext } from '../../AdminContext'; describe('DataTable', () => { it('should render one row per record in the list', async () => { @@ -218,6 +225,39 @@ describe('DataTable', () => { }); }); describe('bulkActionButtons', () => { + it('should not check delete permissions when bulk actions are disabled', () => { + const authProvider: AuthProvider = { + canAccess: jest.fn().mockResolvedValue(true), + checkAuth: jest.fn().mockResolvedValue(undefined), + checkError: jest.fn().mockResolvedValue(undefined), + getPermissions: jest.fn().mockResolvedValue(undefined), + login: jest.fn().mockResolvedValue(undefined), + logout: jest.fn().mockResolvedValue(undefined), + }; + + render( + + + + + + + + + + ); + + expect(authProvider.canAccess).not.toHaveBeenCalled(); + }); + it('should reveal bulk delete button by default on row selection', async () => { render(); const checkboxes = await screen.findAllByRole('checkbox'); diff --git a/packages/ra-ui-materialui/src/list/datatable/DataTable.tsx b/packages/ra-ui-materialui/src/list/datatable/DataTable.tsx index 8ccbfdf3927..a394a0deada 100644 --- a/packages/ra-ui-materialui/src/list/datatable/DataTable.tsx +++ b/packages/ra-ui-materialui/src/list/datatable/DataTable.tsx @@ -139,6 +139,7 @@ export const DataTable = React.forwardRef(function DataTable< const { canAccess: canDelete } = useCanAccess({ resource: resourceFromContext, action: 'delete', + enabled: props.bulkActionButtons !== false, }); const { From 3eaa6747997a96824b7cc9af9cbf4b2890aea6ba Mon Sep 17 00:00:00 2001 From: dawn <93917549+dawNotPoi@users.noreply.github.com> Date: Tue, 1 Sep 2026 17:09:18 +0800 Subject: [PATCH 5/7] test: use real MUI notification transitions --- .../src/layout/Notification.spec.tsx | 72 +++---------------- 1 file changed, 9 insertions(+), 63 deletions(-) diff --git a/packages/ra-ui-materialui/src/layout/Notification.spec.tsx b/packages/ra-ui-materialui/src/layout/Notification.spec.tsx index 97db3a093e6..ecc219ea12a 100644 --- a/packages/ra-ui-materialui/src/layout/Notification.spec.tsx +++ b/packages/ra-ui-materialui/src/layout/Notification.spec.tsx @@ -1,55 +1,6 @@ import * as React from 'react'; import { fireEvent, render, screen, waitFor } from '@testing-library/react'; - -let mockMuiMajor = 5; - -jest.mock('@mui/material', () => { - const actual = jest.requireActual('@mui/material'); - const ReactModule = jest.requireActual('react') as typeof React; - const Mui6Snackbar = ReactModule.forwardRef((props, ref) => { - const wasOpen = ReactModule.useRef(props.open); - - ReactModule.useEffect(() => { - if (wasOpen.current && !props.open) { - props.slotProps?.transition?.onExited?.(null); - props.TransitionProps?.onExited?.(null); - } - wasOpen.current = props.open; - }, [props.open, props.slotProps, props.TransitionProps]); - - ReactModule.useEffect(() => { - if (!props.open) return; - - document.addEventListener('click', props.onClose); - return () => document.removeEventListener('click', props.onClose); - }, [props.open, props.onClose]); - - if (!props.open) return null; - - return ReactModule.createElement( - 'div', - { ref }, - props.children ?? props.message, - props.action, - ReactModule.createElement( - 'button', - { onClick: props.onClose }, - 'Close notification' - ) - ); - }); - const Snackbar = ReactModule.forwardRef((props, ref) => - mockMuiMajor >= 6 - ? ReactModule.createElement(Mui6Snackbar, { ...props, ref }) - : ReactModule.createElement(actual.Snackbar, { ...props, ref }) - ); - - return { - ...actual, - major: { valueOf: () => mockMuiMajor }, - Snackbar, - }; -}); +import { major as muiMajor } from '@mui/material'; import { ConsecutiveNotifications, @@ -58,31 +9,26 @@ import { } from './Notification.stories'; describe('', () => { - afterEach(() => { - mockMuiMajor = 5; - }); - - it.each([6, 7, 9])( - 'should confirm an undoable mutation once when the notification exits with MUI %s', - async muiMajor => { - mockMuiMajor = muiMajor; + (muiMajor >= 6 ? it : it.skip)( + 'should confirm an undoable mutation only once when its notification exits with MUI 6+', + async () => { const deleteOne = jest .fn() .mockImplementation((_resource, { id }) => Promise.resolve({ data: { id } }) ); const dataProvider = { delete: deleteOne } as any; - render(); + const { container } = render( + + ); (await screen.findByText('Delete post 1')).click(); await screen.findByText('Post 1 deleted'); - - fireEvent.click( - screen.getByRole('button', { name: 'Close notification' }) - ); + fireEvent.click(container); await waitFor(() => expect(deleteOne).toHaveBeenCalled()); expect(deleteOne).toHaveBeenCalledTimes(1); + expect(deleteOne).toHaveBeenCalledWith('posts', { id: 1 }); } ); From f631bf929454f6eef79897fa13c53d6724b848f3 Mon Sep 17 00:00:00 2001 From: lprnmns Date: Tue, 1 Sep 2026 17:34:20 +0300 Subject: [PATCH 6/7] fix(datatable): preserve auth provider access guard --- .../src/list/datatable/DataTable.spec.tsx | 33 +++++++++++++++++++ .../src/list/datatable/DataTable.tsx | 2 +- 2 files changed, 34 insertions(+), 1 deletion(-) diff --git a/packages/ra-ui-materialui/src/list/datatable/DataTable.spec.tsx b/packages/ra-ui-materialui/src/list/datatable/DataTable.spec.tsx index 16d94b3cea7..837c04a8f59 100644 --- a/packages/ra-ui-materialui/src/list/datatable/DataTable.spec.tsx +++ b/packages/ra-ui-materialui/src/list/datatable/DataTable.spec.tsx @@ -1,4 +1,5 @@ import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { QueryClient } from '@tanstack/react-query'; import * as React from 'react'; import { ResourceContextProvider, @@ -225,6 +226,38 @@ describe('DataTable', () => { }); }); describe('bulkActionButtons', () => { + it('should not enable delete permission queries without an auth provider', async () => { + const queryClient = new QueryClient(); + + render( + + + + } + data={[{ id: 1 }]} + isPending={false} + onSelect={jest.fn()} + onToggleItem={jest.fn()} + selectedIds={[]} + total={1} + > + + + + + + ); + + await waitFor(() => { + const [query] = queryClient.getQueryCache().findAll({ + queryKey: ['auth', 'canAccess'], + }); + expect(query.state.fetchStatus).toBe('idle'); + expect(query.state.data).toBeUndefined(); + }); + }); + it('should not check delete permissions when bulk actions are disabled', () => { const authProvider: AuthProvider = { canAccess: jest.fn().mockResolvedValue(true), diff --git a/packages/ra-ui-materialui/src/list/datatable/DataTable.tsx b/packages/ra-ui-materialui/src/list/datatable/DataTable.tsx index a394a0deada..35ce814a0f5 100644 --- a/packages/ra-ui-materialui/src/list/datatable/DataTable.tsx +++ b/packages/ra-ui-materialui/src/list/datatable/DataTable.tsx @@ -139,7 +139,7 @@ export const DataTable = React.forwardRef(function DataTable< const { canAccess: canDelete } = useCanAccess({ resource: resourceFromContext, action: 'delete', - enabled: props.bulkActionButtons !== false, + ...(props.bulkActionButtons === false ? { enabled: false } : {}), }); const { From c28bf6a9f0b2e8c53744da16d577f8500ba7829d Mon Sep 17 00:00:00 2001 From: fzaninotto Date: Tue, 1 Sep 2026 17:04:02 +0200 Subject: [PATCH 7/7] add test --- .../src/RichTextInput.spec.tsx | 23 ++++++- .../src/RichTextInput.stories.tsx | 61 ++++++++++++++++++- 2 files changed, 81 insertions(+), 3 deletions(-) diff --git a/packages/ra-input-rich-text/src/RichTextInput.spec.tsx b/packages/ra-input-rich-text/src/RichTextInput.spec.tsx index d3cf7c1cb83..37ed23469b4 100644 --- a/packages/ra-input-rich-text/src/RichTextInput.spec.tsx +++ b/packages/ra-input-rich-text/src/RichTextInput.spec.tsx @@ -1,7 +1,8 @@ import * as React from 'react'; import expect from 'expect'; -import { render, waitFor } from '@testing-library/react'; -import { Basic } from './RichTextInput.stories'; +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { Basic, EditorRecreation } from './RichTextInput.stories'; describe('', () => { it('should update its content when fields value changes and add a trailing break to it', async () => { @@ -23,4 +24,22 @@ describe('', () => { ); }); }); + + it('should update its content when the editor is recreated while the field value changes', async () => { + const { container } = render(); + + await waitFor(() => { + expect(container.querySelector('.ProseMirror')?.innerHTML).toEqual( + '

This post is a draft, you can edit it.

' + ); + }); + + await userEvent.click(screen.getByText('Switch post')); + + await waitFor(() => { + expect(container.querySelector('.ProseMirror')?.innerHTML).toEqual( + '

This post is published, it is read-only.

' + ); + }); + }); }); diff --git a/packages/ra-input-rich-text/src/RichTextInput.stories.tsx b/packages/ra-input-rich-text/src/RichTextInput.stories.tsx index e01b3b673ec..e1a2606dd3b 100644 --- a/packages/ra-input-rich-text/src/RichTextInput.stories.tsx +++ b/packages/ra-input-rich-text/src/RichTextInput.stories.tsx @@ -17,7 +17,7 @@ import { Toolbar as RAToolbar, SaveButton, } from 'ra-ui-materialui'; -import { useWatch } from 'react-hook-form'; +import { useFormContext, useWatch } from 'react-hook-form'; import fakeRestDataProvider from 'ra-data-fakerest'; import { Routes, Route } from 'react-router-dom'; import Mention from '@tiptap/extension-mention'; @@ -112,6 +112,65 @@ export const ReadOnly = (props: Partial) => ( ); +const postsToSwitch = [ + { body: '

This post is a draft, you can edit it.

', readOnly: false }, + { body: '

This post is published, it is read-only.

', readOnly: true }, +]; + +const SwitchPostButton = ({ + postIndex, + onSwitch, +}: { + postIndex: number; + onSwitch: (index: number) => void; +}) => { + const { setValue } = useFormContext(); + return ( + + ); +}; + +/** + * Changing the value and the readOnly prop at the same time recreates the editor + * while the content sync effect still references the previous (destroyed) one. + * Clicking on "Switch post" used to throw + * "Cannot read properties of null (reading 'commands')". + */ +export const EditorRecreation = (props: Partial) => { + const [postIndex, setPostIndex] = React.useState(0); + return ( + + + + {}} + {...props} + > + + + + + + + + ); +}; + export const Small = (props: Partial) => (