Compare commits

...

2 Commits

Author SHA1 Message Date
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 21 additions and 3 deletions

View File

@@ -70,11 +70,21 @@ 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');
});

View File

@@ -86,7 +86,15 @@ export const DynamicEditableTitle = memo(
});
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) {