diff --git a/packages/pluggableWidgets/rich-text-web/CHANGELOG.md b/packages/pluggableWidgets/rich-text-web/CHANGELOG.md index 988944d6cd..4ef2a94096 100644 --- a/packages/pluggableWidgets/rich-text-web/CHANGELOG.md +++ b/packages/pluggableWidgets/rich-text-web/CHANGELOG.md @@ -6,6 +6,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), ## [Unreleased] +### Fixed + +- We fixed an issue where the On change action ran when leaving the editor without changing its content. + ## [5.1.0] - 2026-09-29 ### Added diff --git a/packages/pluggableWidgets/rich-text-web/src/__tests__/RichText.runtime.spec.tsx b/packages/pluggableWidgets/rich-text-web/src/__tests__/RichText.runtime.spec.tsx index 5e492c4e41..f193b5301b 100644 --- a/packages/pluggableWidgets/rich-text-web/src/__tests__/RichText.runtime.spec.tsx +++ b/packages/pluggableWidgets/rich-text-web/src/__tests__/RichText.runtime.spec.tsx @@ -123,6 +123,35 @@ describe("Rich Text runtime behaviour", () => { expect(onChange.execute).toHaveBeenCalledTimes(1); }); + it("does not execute onChange on blur without an edit when onChangeType is onLeave", () => { + const onChange = actionValue(); + const onBlur = actionValue(); + const { container } = render( + + ); + flush(); + + fireEvent.focus(getEditorDom(container)); + fireEvent.blur(getEditorDom(container)); + + expect(onChange.execute).not.toHaveBeenCalled(); + expect(onBlur.execute).toHaveBeenCalledTimes(1); + }); + + it("executes onChange on leave once per edit when onChangeType is onLeave", () => { + const onChange = actionValue(); + const { container } = render(); + + fireEvent.focus(getEditorDom(container)); + edit(container, "

edited

"); + fireEvent.blur(getEditorDom(container)); + expect(onChange.execute).toHaveBeenCalledTimes(1); + + fireEvent.focus(getEditorDom(container)); + fireEvent.blur(getEditorDom(container)); + expect(onChange.execute).toHaveBeenCalledTimes(1); + }); + it("executes onChange on edit and not on blur when onChangeType is onDataChange", () => { const onChange = actionValue(); const { container } = render( @@ -176,6 +205,7 @@ describe("Rich Text runtime behaviour", () => { rerender(); fireEvent.focus(getEditorDom(container)); + edit(container, "

edited

"); fireEvent.blur(getEditorDom(container)); expect(second.onFocus.execute).toHaveBeenCalledTimes(1); diff --git a/packages/pluggableWidgets/rich-text-web/src/components/Editor.tsx b/packages/pluggableWidgets/rich-text-web/src/components/Editor.tsx index 7058ef7daf..4b089315d6 100644 --- a/packages/pluggableWidgets/rich-text-web/src/components/Editor.tsx +++ b/packages/pluggableWidgets/rich-text-web/src/components/Editor.tsx @@ -252,6 +252,9 @@ const Editor = forwardRef((props, ref) => { const uploadEnabled = enableDefaultUpload || imageSource == null; const configRef = useRef({ enableDefaultUpload: uploadEnabled, editable: !readOnly }); configRef.current = { enableDefaultUpload: uploadEnabled, editable: !readOnly }; + // Set only by a genuine edit (`onUpdate`); the external-value sync below uses + // `emitUpdate: false`, so it never marks the editor dirty. + const isDirtyRef = useRef(false); const extensions = useMemo( () => [ @@ -367,6 +370,7 @@ const Editor = forwardRef((props, ref) => { content: defaultValue || "", editable: !readOnly, onUpdate: ({ editor }) => { + isDirtyRef.current = true; const html = editor.isEmpty ? "" : editor.getHTML(); onUpdate?.(html); }, @@ -375,7 +379,8 @@ const Editor = forwardRef((props, ref) => { }, onBlur: () => { executeAction(actionRef.current.onBlur); - if (actionRef.current.onChangeType === "onLeave") { + if (actionRef.current.onChangeType === "onLeave" && isDirtyRef.current) { + isDirtyRef.current = false; executeAction(actionRef.current.onChange); } },