diff --git a/superset-frontend/packages/superset-ui-core/src/components/DynamicEditableTitle/DynamicEditableTitle.regression.test.tsx b/superset-frontend/packages/superset-ui-core/src/components/DynamicEditableTitle/DynamicEditableTitle.regression.test.tsx
index 010f6081d81..d990221f4d9 100644
--- a/superset-frontend/packages/superset-ui-core/src/components/DynamicEditableTitle/DynamicEditableTitle.regression.test.tsx
+++ b/superset-frontend/packages/superset-ui-core/src/components/DynamicEditableTitle/DynamicEditableTitle.regression.test.tsx
@@ -87,4 +87,29 @@ test('prop changes mid-edit do not clobber unsaved typing', async () => {
expect(input.value).toBe('FooX');
rerender();
expect(input.value).toBe('FooX');
+ // Locks in commit semantics: blur after a real edit must persist the
+ // user's typed value, even when a competing parent-driven title arrived
+ // mid-edit.
+ fireEvent.blur(input);
+ expect(onSave).toHaveBeenCalledWith('FooX');
+});
+
+test('passive focus then parent-driven title change then blur does not revert', () => {
+ // Phantom-revert scenario: user clicks the input but does not type, the
+ // parent autosaves a new title from elsewhere, then the user blurs. The
+ // component must NOT call onSave with the stale local value, otherwise it
+ // would silently overwrite the parent's update.
+ const onSave = jest.fn();
+ const props = {
+ placeholder: 'placeholder',
+ canEdit: true,
+ label: 'Title',
+ onSave,
+ };
+ const { rerender } = render();
+ const input = screen.getByRole('textbox') as HTMLInputElement;
+ userEvent.click(input);
+ rerender();
+ fireEvent.blur(input);
+ expect(onSave).not.toHaveBeenCalled();
});
diff --git a/superset-frontend/packages/superset-ui-core/src/components/DynamicEditableTitle/index.tsx b/superset-frontend/packages/superset-ui-core/src/components/DynamicEditableTitle/index.tsx
index 8d84f5adb1e..70fa0d41e68 100644
--- a/superset-frontend/packages/superset-ui-core/src/components/DynamicEditableTitle/index.tsx
+++ b/superset-frontend/packages/superset-ui-core/src/components/DynamicEditableTitle/index.tsx
@@ -81,6 +81,11 @@ export const DynamicEditableTitle = memo(
const sizerRef = useRef(null);
const inputRef = useRef(null);
+ // Tracks whether the user has actually typed since entering edit mode.
+ // Gates onSave so that passive focus (click without typing) followed by a
+ // parent-driven title change and blur does not silently revert the
+ // parent's update with our stale currentTitle.
+ const dirtyRef = useRef(false);
const { width: containerWidth, ref: containerRef } = useResizeDetector({
refreshMode: 'debounce',
});
@@ -146,10 +151,19 @@ export const DynamicEditableTitle = memo(
return;
}
const formattedTitle = currentTitle.trim();
- setCurrentTitle(formattedTitle);
- if (title !== formattedTitle) {
+ // Only commit when the user actually typed. Passive focus must not
+ // overwrite a parent-driven title change that landed mid-edit.
+ if (dirtyRef.current && title !== formattedTitle) {
+ setCurrentTitle(formattedTitle);
onSave(formattedTitle);
+ } else if (!dirtyRef.current) {
+ // Drop any stale local state and resync to the latest title prop so a
+ // subsequent edit starts from the current parent value.
+ setCurrentTitle(title);
+ } else {
+ setCurrentTitle(formattedTitle);
}
+ dirtyRef.current = false;
setIsEditing(false);
}, [canEdit, currentTitle, onSave, title]);
@@ -166,6 +180,7 @@ export const DynamicEditableTitle = memo(
if (!isEditing) {
setIsEditing(true);
}
+ dirtyRef.current = true;
setCurrentTitle(ev.target.value);
},
[canEdit, isEditing],