Skip to content

Commit 6560a51

Browse files
fix: stabilize React keys in MultiPairInputModal
Rows used `key={\`row-\${index}\`}`, the well-known React anti-pattern where removing an item from the middle causes React to reuse DOM nodes across rows. Controlled inputs masked the worst symptom (visible value stayed in sync because state is rewritten on every render), but per-DOM-node state — focus, IME composition, autofill, scroll — leaked between rows after a removal. Give each row a stable monotonic id (mirroring the React Native demo), key off `row.id`, and update/remove rows by id rather than by index. The positional `data-testid` attributes are kept as-is so Appium selectors unchanged. Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 81afeaa commit 6560a51

2 files changed

Lines changed: 26 additions & 24 deletions

File tree

examples/demo/src/components/modals/MultiPairInputModal.tsx

Lines changed: 13 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,10 @@ import { MdClose } from 'react-icons/md';
44

55
import ModalShell from './ModalShell';
66

7-
type Row = { key: string; value: string };
7+
type Row = { id: number; key: string; value: string };
8+
9+
let nextRowId = 0;
10+
const createRow = (): Row => ({ id: nextRowId++, key: '', value: '' });
811

912
interface MultiPairInputModalProps {
1013
open: boolean;
@@ -23,11 +26,11 @@ const MultiPairInputModal: FC<MultiPairInputModalProps> = ({
2326
onClose,
2427
onSubmit,
2528
}) => {
26-
const [rows, setRows] = useState<Row[]>([{ key: '', value: '' }]);
29+
const [rows, setRows] = useState<Row[]>(() => [createRow()]);
2730

2831
useEffect(() => {
2932
if (open) {
30-
setRows([{ key: '', value: '' }]);
33+
setRows([createRow()]);
3134
}
3235
}, [open]);
3336

@@ -57,14 +60,14 @@ const MultiPairInputModal: FC<MultiPairInputModalProps> = ({
5760
>
5861
<h3>{title}</h3>
5962
{rows.map((row, index) => (
60-
<div key={`row-${index}`}>
63+
<div key={row.id}>
6164
<div className="inline-fields row-with-remove">
6265
<input
6366
value={row.key}
6467
onChange={(event) =>
6568
setRows((prev) =>
66-
prev.map((entry, entryIndex) =>
67-
entryIndex === index ? { ...entry, key: event.target.value } : entry,
69+
prev.map((entry) =>
70+
entry.id === row.id ? { ...entry, key: event.target.value } : entry,
6871
),
6972
)
7073
}
@@ -75,8 +78,8 @@ const MultiPairInputModal: FC<MultiPairInputModalProps> = ({
7578
value={row.value}
7679
onChange={(event) =>
7780
setRows((prev) =>
78-
prev.map((entry, entryIndex) =>
79-
entryIndex === index ? { ...entry, value: event.target.value } : entry,
81+
prev.map((entry) =>
82+
entry.id === row.id ? { ...entry, value: event.target.value } : entry,
8083
),
8184
)
8285
}
@@ -87,9 +90,7 @@ const MultiPairInputModal: FC<MultiPairInputModalProps> = ({
8790
<button
8891
type="button"
8992
className="delete-btn"
90-
onClick={() =>
91-
setRows((prev) => prev.filter((_, entryIndex) => entryIndex !== index))
92-
}
93+
onClick={() => setRows((prev) => prev.filter((entry) => entry.id !== row.id))}
9394
>
9495
<MdClose />
9596
</button>
@@ -101,7 +102,7 @@ const MultiPairInputModal: FC<MultiPairInputModalProps> = ({
101102
<button
102103
type="button"
103104
className="text-btn text-btn-center"
104-
onClick={() => setRows((prev) => [...prev, { key: '', value: '' }])}
105+
onClick={() => setRows((prev) => [...prev, createRow()])}
105106
data-testid="multipair_add_row_button"
106107
>
107108
+ Add Row

examples/demo_pods/src/components/modals/MultiPairInputModal.tsx

Lines changed: 13 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,10 @@ import { MdClose } from 'react-icons/md';
44

55
import ModalShell from './ModalShell';
66

7-
type Row = { key: string; value: string };
7+
type Row = { id: number; key: string; value: string };
8+
9+
let nextRowId = 0;
10+
const createRow = (): Row => ({ id: nextRowId++, key: '', value: '' });
811

912
interface MultiPairInputModalProps {
1013
open: boolean;
@@ -23,11 +26,11 @@ const MultiPairInputModal: FC<MultiPairInputModalProps> = ({
2326
onClose,
2427
onSubmit,
2528
}) => {
26-
const [rows, setRows] = useState<Row[]>([{ key: '', value: '' }]);
29+
const [rows, setRows] = useState<Row[]>(() => [createRow()]);
2730

2831
useEffect(() => {
2932
if (open) {
30-
setRows([{ key: '', value: '' }]);
33+
setRows([createRow()]);
3134
}
3235
}, [open]);
3336

@@ -57,14 +60,14 @@ const MultiPairInputModal: FC<MultiPairInputModalProps> = ({
5760
>
5861
<h3>{title}</h3>
5962
{rows.map((row, index) => (
60-
<div key={`row-${index}`}>
63+
<div key={row.id}>
6164
<div className="inline-fields row-with-remove">
6265
<input
6366
value={row.key}
6467
onChange={(event) =>
6568
setRows((prev) =>
66-
prev.map((entry, entryIndex) =>
67-
entryIndex === index ? { ...entry, key: event.target.value } : entry,
69+
prev.map((entry) =>
70+
entry.id === row.id ? { ...entry, key: event.target.value } : entry,
6871
),
6972
)
7073
}
@@ -75,8 +78,8 @@ const MultiPairInputModal: FC<MultiPairInputModalProps> = ({
7578
value={row.value}
7679
onChange={(event) =>
7780
setRows((prev) =>
78-
prev.map((entry, entryIndex) =>
79-
entryIndex === index ? { ...entry, value: event.target.value } : entry,
81+
prev.map((entry) =>
82+
entry.id === row.id ? { ...entry, value: event.target.value } : entry,
8083
),
8184
)
8285
}
@@ -87,9 +90,7 @@ const MultiPairInputModal: FC<MultiPairInputModalProps> = ({
8790
<button
8891
type="button"
8992
className="delete-btn"
90-
onClick={() =>
91-
setRows((prev) => prev.filter((_, entryIndex) => entryIndex !== index))
92-
}
93+
onClick={() => setRows((prev) => prev.filter((entry) => entry.id !== row.id))}
9394
>
9495
<MdClose />
9596
</button>
@@ -101,7 +102,7 @@ const MultiPairInputModal: FC<MultiPairInputModalProps> = ({
101102
<button
102103
type="button"
103104
className="text-btn text-btn-center"
104-
onClick={() => setRows((prev) => [...prev, { key: '', value: '' }])}
105+
onClick={() => setRows((prev) => [...prev, createRow()])}
105106
data-testid="multipair_add_row_button"
106107
>
107108
+ Add Row

0 commit comments

Comments
 (0)