Compare commits

...

3 Commits

Author SHA1 Message Date
Mehmet Salih Yavuz
bf8bbfe514 fix(DynamicEditableTitle): gate onSave on user input to prevent phantom revert
Track a dirtyRef that flips true only when the user actually types. handleBlur
now skips onSave when no edit happened, so passive focus followed by a
parent-driven title change followed by blur no longer silently reverts the
parent's update with our stale local value.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 14:38:41 +03:00
Mehmet Salih Yavuz
65aea59df1 fix(DynamicEditableTitle): keep effect deps as [title]
Including isEditing in the dep array re-ran the sync effect when
isEditing flipped from true to false on blur — which clobbered the
just-committed value if the parent's title prop hadn't propagated yet
(e.g. parents that don't update local state in the onSave callback,
which is also the pattern in Header.test.tsx).

Read isEditing via closure instead so the effect only fires when the
title prop actually changes. handleBlur still syncs currentTitle on
commit, so the consistency invariant is preserved.
2026-05-04 20:22:53 +03:00
Mehmet Salih Yavuz
4bb6c8e932 fix(DynamicEditableTitle): preserve in-flight edits when title prop changes
The effect at index.tsx:88 called setCurrentTitle(title) whenever the
title prop changed — including mid-edit, which clobbered unsaved typing.
Gate it on !isEditing so a parent re-render with a new title doesn't
overwrite what the user has typed. handleBlur already syncs currentTitle
on commit, so the consistency invariant is preserved.

Also fix the existing 'prop changes mid-edit do not clobber' regression
test, which rerendered Harness with the same initialTitle="Foo" — the
prop the component received never actually changed, so the test passed
even on broken code. Rerender DynamicEditableTitle directly with a
different title prop so the sync effect actually runs.
2026-05-04 19:42:14 +03:00
2 changed files with 63 additions and 5 deletions

View File

@@ -70,11 +70,46 @@ test('a change event that arrives before isEditing flips is not dropped', () =>
});
test('prop changes mid-edit do not clobber unsaved typing', async () => {
const { rerender } = render(<Harness initialTitle="Foo" />);
// Rerender DynamicEditableTitle directly with a changed title prop so the
// sync effect actually runs. Going through Harness would not exercise the
// bug because Harness owns its own state and only reads initialTitle once.
const onSave = jest.fn();
const props = {
placeholder: 'placeholder',
canEdit: true,
label: 'Title',
onSave,
};
const { rerender } = render(<DynamicEditableTitle {...props} title="Foo" />);
const input = screen.getByRole('textbox') as HTMLInputElement;
userEvent.click(input);
await userEvent.type(input, 'X', { delay: 1 });
expect(input.value).toBe('FooX');
rerender(<Harness initialTitle="Foo" />);
rerender(<DynamicEditableTitle {...props} title="Bar" />);
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(<DynamicEditableTitle {...props} title="Foo" />);
const input = screen.getByRole('textbox') as HTMLInputElement;
userEvent.click(input);
rerender(<DynamicEditableTitle {...props} title="Bar" />);
fireEvent.blur(input);
expect(onSave).not.toHaveBeenCalled();
});

View File

@@ -81,12 +81,25 @@ export const DynamicEditableTitle = memo(
const sizerRef = useRef<HTMLSpanElement>(null);
const inputRef = useRef<InputRef>(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',
});
useEffect(() => {
setCurrentTitle(title);
// Don't overwrite in-flight user input when the parent re-renders with a
// new title prop mid-edit. handleBlur already syncs currentTitle on commit;
// re-running this effect when isEditing flips would resync to a stale
// title prop, so isEditing is intentionally read via closure rather than
// listed as a dep.
if (!isEditing) {
setCurrentTitle(title);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [title]);
useEffect(() => {
if (isEditing) {
@@ -138,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]);
@@ -158,6 +180,7 @@ export const DynamicEditableTitle = memo(
if (!isEditing) {
setIsEditing(true);
}
dirtyRef.current = true;
setCurrentTitle(ev.target.value);
},
[canEdit, isEditing],