diff --git a/web/src/pages/Setting/Ratio/components/GroupTable.jsx b/web/src/pages/Setting/Ratio/components/GroupTable.jsx
index 9cf08b25..3594d3e8 100644
--- a/web/src/pages/Setting/Ratio/components/GroupTable.jsx
+++ b/web/src/pages/Setting/Ratio/components/GroupTable.jsx
@@ -1,4 +1,4 @@
-import React, { useState, useCallback, useMemo } from 'react';
+import React, { useState, useCallback, useMemo, useRef } from 'react';
import {
Button,
Input,
@@ -61,60 +61,63 @@ export function serializeGroupTable(rows) {
};
}
-export default function GroupTable({
- groupRatio,
- userUsableGroups,
- onChange,
-}) {
+export default function GroupTable({ groupRatio, userUsableGroups, onChange }) {
const { t } = useTranslation();
const [rows, setRows] = useState(() =>
buildRows(groupRatio, userUsableGroups),
);
- const emitChange = useCallback(
- (newRows) => {
- setRows(newRows);
- onChange?.(serializeGroupTable(newRows));
- },
- [onChange],
- );
+ // Use functional setRows to keep updateRow/addRow/removeRow referentially
+ // stable, preventing columns useMemo from rebuilding on every keystroke
+ // which causes the Input cursor to jump to end (cursor reset bug).
+ const onChangeRef = useRef(onChange);
+ onChangeRef.current = onChange;
+
+ const emitAndSet = useCallback((updater) => {
+ setRows((prev) => {
+ const next = typeof updater === 'function' ? updater(prev) : updater;
+ onChangeRef.current?.(serializeGroupTable(next));
+ return next;
+ });
+ }, []);
const updateRow = useCallback(
(id, field, value) => {
- const next = rows.map((r) =>
- r._id === id ? { ...r, [field]: value } : r,
+ emitAndSet((prev) =>
+ prev.map((r) => (r._id === id ? { ...r, [field]: value } : r)),
);
- emitChange(next);
},
- [rows, emitChange],
+ [emitAndSet],
);
const addRow = useCallback(() => {
- const existingNames = new Set(rows.map((r) => r.name));
- let counter = 1;
- let newName = `group_${counter}`;
- while (existingNames.has(newName)) {
- counter++;
- newName = `group_${counter}`;
- }
- emitChange([
- ...rows,
- {
- _id: uid(),
- name: newName,
- ratio: 1,
- selectable: true,
- description: '',
- },
- ]);
- }, [rows, emitChange]);
+ emitAndSet((prev) => {
+ const existingNames = new Set(prev.map((r) => r.name));
+ let counter = 1;
+ let newName = `group_${counter}`;
+ while (existingNames.has(newName)) {
+ counter++;
+ newName = `group_${counter}`;
+ }
+ return [
+ ...prev,
+ {
+ _id: uid(),
+ name: newName,
+ ratio: 1,
+ selectable: true,
+ description: '',
+ },
+ ];
+ });
+ }, [emitAndSet]);
const removeRow = useCallback(
(id) => {
- emitChange(rows.filter((r) => r._id !== id));
+ emitAndSet((prev) => prev.filter((r) => r._id !== id));
},
- [rows, emitChange],
+ [emitAndSet],
);
const groupNames = useMemo(() => rows.map((r) => r.name), [rows]);
@@ -127,6 +130,11 @@ export default function GroupTable({
return new Set(Object.keys(counts).filter((k) => counts[k] > 1));
}, [groupNames]);
+ // Use ref so column render functions always read the latest duplicate set
+ // without adding duplicateNames to columns deps (which would break cursor).
+ const duplicateNamesRef = useRef(duplicateNames);
+ duplicateNamesRef.current = duplicateNames;
+
const columns = useMemo(
() => [
{
@@ -138,7 +146,9 @@ export default function GroupTable({
updateRow(record._id, 'name', v)}
/>
),
@@ -212,7 +222,7 @@ export default function GroupTable({
),
},
],
- [t, duplicateNames, updateRow, removeRow],
+ [t, updateRow, removeRow],
);
return (
@@ -223,9 +233,7 @@ export default function GroupTable({
rowKey='_id'
hidePagination
size='small'
- empty={
-