fix(admin): write group rows in place so drag keys stay stable
The previous fix inherited the drag key onto a replacement row object, but the row data is reactive: Vue hands the renderer a fresh proxy for every new object, so the WeakMap still missed and minted a new key on each keystroke. That remounted the row and dropped input focus after one character, so editing 执行价, 原价, 促销标签, 使用积分, 兑换时长 and 会员有效时间 was unusable. updateRepeaterItem now writes through the existing row instead of spreading a new object, so the row identity survives across renders and the drag key stays valid. The emitted array is still a fresh array, so parents re-render normally, and rows that are not objects still fall back to a new object. Verified in a real browser against the actual payment/vip schema: all six fields keep the same DOM node across repeated keystrokes, typed values reach the model, and add/remove still work. The contract test now simulates Vue reactive proxying and asserts row identity is preserved; it fails against the previous implementation. Full suite: 663 tests passing.
This commit is contained in:
1 parent
0bc72616e5
commit
7a0445fc4a
2 files changed
+78
-31
No files matched your search
@@ -1212,16 +1212,16 @@ function removeRepeaterItem(index) {
|
||||
|
||||
function updateRepeaterItem(index, fieldKey, value) {
|
||||
const current = Array.isArray(props.modelValue) ? [...props.modelValue] : []
|
||||
const previous = current[index]
|
||||
const next = previous && typeof previous === 'object' ? { ...previous, [fieldKey]: value } : { [fieldKey]: value }
|
||||
// 行对象每次都被替换成新引用,WeakMap 认不出它就会生成新 key,整行随之
|
||||
// 重挂载、输入框失焦(表现为打一个字就退出编辑)。把旧 key 继承给新对象,
|
||||
// 行 key 在整行生命周期内保持稳定。
|
||||
const inheritedKey = previous && typeof previous === 'object' ? dragRowKeys.get(previous) : null
|
||||
if (inheritedKey) {
|
||||
dragRowKeys.set(next, inheritedKey)
|
||||
const row = current[index]
|
||||
if (row && typeof row === 'object') {
|
||||
// 必须原地写,不能展开成新对象:drag key 存在以「对象身份」为键的 WeakMap
|
||||
// 里,而行数据是 reactive 的——换一个新对象后,渲染时读到的是它的一个新
|
||||
// reactive 代理,WeakMap 认不出它便生成新 key,整行重挂载、输入框失焦,
|
||||
// 表现为打一个字就退出编辑。经代理原地写既保持身份,也照样触发响应式。
|
||||
row[fieldKey] = value
|
||||
} else {
|
||||
current[index] = { [fieldKey]: value }
|
||||
}
|
||||
current[index] = next
|
||||
emit('update:modelValue', current)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* AksField 的 group / repeater 行 key 稳定性契约。
|
||||
* AksField 的 group / repeater 行身份稳定性契约。
|
||||
*
|
||||
* 背景:inline 展开模式用 vuedraggable 渲染行,vuedraggable 的 computeNodes 会把
|
||||
* `node.key = getKey(element)`,而行 key 由 AksField 的 repeaterDragKey 提供。
|
||||
* 服务端历史行没有 _id,key 来自 WeakMap;而 updateRepeaterItem 每次输入都会把行
|
||||
* 对象展开成新引用,WeakMap 认不出它便生成新 key,整行随之重挂载、输入框失焦,
|
||||
* 表现为「打一个字就退出编辑」。本测试直接跑这两个真实函数来锁定该不变量。
|
||||
* `node.key = getKey(element)`,而行 key 由 AksField 的 repeaterDragKey 提供。服务端
|
||||
* 历史行没有 _id,key 来自以「对象身份」为键的 WeakMap。
|
||||
*
|
||||
* 关键坑:行数据是 reactive 的。updateRepeaterItem 一旦把行展开成一个新对象,渲染时
|
||||
* 读到的是那个新对象的又一个 reactive 代理——身份再次改变,WeakMap 认不出它便生成
|
||||
* 新 key,整行随之重挂载、输入框失焦,表现为「打一个字就退出编辑」。所以必须原地写,
|
||||
* 让行身份跨渲染保持不变。本测试直接跑这两个真实函数来锁定该不变量。
|
||||
*/
|
||||
|
||||
const assert = require('node:assert/strict');
|
||||
@@ -21,6 +24,26 @@ const fieldVue = fs.readFileSync(
|
||||
'utf8'
|
||||
);
|
||||
|
||||
/**
|
||||
* 模拟 Vue 的 reactive():每个原始对象只包一次代理并缓存,之后读多少次都返回同一个
|
||||
* 代理。这正是让 WeakMap 身份在渲染与更新之间保持一致(或不一致)的原因。
|
||||
*/
|
||||
const proxyCache = new WeakMap();
|
||||
const proxySet = new WeakSet();
|
||||
function reactiveOf(value) {
|
||||
if (!value || typeof value !== 'object' || proxySet.has(value)) return value;
|
||||
let proxy = proxyCache.get(value);
|
||||
if (!proxy) {
|
||||
proxy = new Proxy(value, {
|
||||
get(target, key, receiver) { return Reflect.get(target, key, receiver); },
|
||||
set(target, key, next) { target[key] = next; return true; },
|
||||
});
|
||||
proxyCache.set(value, proxy);
|
||||
proxySet.add(proxy);
|
||||
}
|
||||
return proxy;
|
||||
}
|
||||
|
||||
/** 取一整行声明(WeakMap / 种子计数器都没有花括号体)。 */
|
||||
function declaration(source, marker) {
|
||||
const start = source.indexOf(marker);
|
||||
@@ -56,53 +79,77 @@ const prelude = [
|
||||
/** 用真实的 props / emit 形状驱动这两个函数。 */
|
||||
function makeField(rows) {
|
||||
const emitted = [];
|
||||
const props = { modelValue: rows };
|
||||
// props.modelValue 是 reactive 视图,正如真实组件收到的那样。
|
||||
const props = { modelValue: rows.map(reactiveOf) };
|
||||
const emit = (event, value) => emitted.push({ event, value });
|
||||
const factory = new Function('props', 'emit', `${prelude}\nreturn { repeaterDragKey, updateRepeaterItem };`);
|
||||
const api = factory(props, emit);
|
||||
return { api, props, emitted };
|
||||
// 父组件把 emit 出来的数组写回 reactive 树,读出来时行又被包成代理。
|
||||
const roundTrip = () => {
|
||||
props.modelValue = emitted[emitted.length - 1].value.map(reactiveOf);
|
||||
};
|
||||
return { api, props, emitted, roundTrip };
|
||||
}
|
||||
|
||||
test('group 行在连续输入期间保持同一个 drag key', () => {
|
||||
test('连续输入期间行身份不变,drag key 保持稳定', () => {
|
||||
// 服务端历史行:没有 _id,key 只能由 WeakMap 派生。
|
||||
const serverRows = [
|
||||
{ points: 500, level: 1, time: 30, unit: 'day' },
|
||||
{ points: 5000, level: 1, time: 'Permanent' },
|
||||
];
|
||||
const { api, props, emitted } = makeField(serverRows);
|
||||
const firstKey = api.repeaterDragKey(serverRows[0]);
|
||||
const secondKey = api.repeaterDragKey(serverRows[1]);
|
||||
const { api, props, emitted, roundTrip } = makeField(serverRows);
|
||||
const firstKey = api.repeaterDragKey(props.modelValue[0]);
|
||||
const secondKey = api.repeaterDragKey(props.modelValue[1]);
|
||||
assert.notStrictEqual(firstKey, secondKey, '两行必须各有一个 key');
|
||||
const firstRow = props.modelValue[0];
|
||||
|
||||
// 模拟逐字输入:每次 emit 后父组件把新数组回填 props.modelValue。
|
||||
let rows = serverRows;
|
||||
for (const value of ['5', '50', '500']) {
|
||||
api.updateRepeaterItem(0, 'points', value);
|
||||
rows = emitted[emitted.length - 1].value;
|
||||
props.modelValue = rows;
|
||||
roundTrip();
|
||||
}
|
||||
|
||||
assert.strictEqual(rows[0].points, '500', '输入值应写进行内');
|
||||
// 核心不变量:行对象身份必须跨渲染保持不变,否则 WeakMap 会发新 key。
|
||||
assert.strictEqual(props.modelValue[0], firstRow, '行身份必须在编辑期间保持不变');
|
||||
assert.strictEqual(
|
||||
api.repeaterDragKey(rows[0]),
|
||||
api.repeaterDragKey(props.modelValue[0]),
|
||||
firstKey,
|
||||
'行 key 必须在编辑期间保持稳定,否则整行重挂载、输入框失焦'
|
||||
);
|
||||
assert.strictEqual(api.repeaterDragKey(rows[1]), secondKey, '未编辑的行 key 不应变');
|
||||
assert.strictEqual(api.repeaterDragKey(props.modelValue[1]), secondKey, '未编辑的行 key 不应变');
|
||||
});
|
||||
|
||||
test('emit 出去的是新数组,父组件仍会重渲染', () => {
|
||||
const { api, props, emitted, roundTrip } = makeField([{ points: 500 }]);
|
||||
const before = props.modelValue;
|
||||
api.updateRepeaterItem(0, 'points', '9');
|
||||
const after = emitted[emitted.length - 1].value;
|
||||
assert.notStrictEqual(after, before, '必须发出新数组,否则父组件不会重渲染');
|
||||
roundTrip();
|
||||
assert.strictEqual(props.modelValue[0].points, '9');
|
||||
});
|
||||
|
||||
test('行不是对象时退化为新建,不抛错', () => {
|
||||
const { api, props, roundTrip } = makeField([]);
|
||||
api.updateRepeaterItem(0, 'points', '7');
|
||||
roundTrip();
|
||||
assert.strictEqual(props.modelValue.length, 1);
|
||||
assert.strictEqual(props.modelValue[0].points, '7');
|
||||
});
|
||||
|
||||
test('带 _id 的行仍以 _id 作为 key', () => {
|
||||
const rows = [{ _id: 'rpt_1', points: 100 }, { _id: 'rpt_2', points: 200 }];
|
||||
const { api } = makeField(rows);
|
||||
assert.strictEqual(api.repeaterDragKey(rows[0]), 'rpt_1');
|
||||
assert.strictEqual(api.repeaterDragKey(rows[1]), 'rpt_2');
|
||||
const { api, props } = makeField(rows);
|
||||
assert.strictEqual(api.repeaterDragKey(props.modelValue[0]), 'rpt_1');
|
||||
assert.strictEqual(api.repeaterDragKey(props.modelValue[1]), 'rpt_2');
|
||||
});
|
||||
|
||||
test('新增行获得独立 key,不与既有行冲突', () => {
|
||||
const serverRows = [{ points: 500 }, { points: 5000 }];
|
||||
const { api } = makeField(serverRows);
|
||||
const keys = new Set(serverRows.map((row) => api.repeaterDragKey(row)));
|
||||
const { api, props } = makeField([{ points: 500 }, { points: 5000 }]);
|
||||
const keys = new Set(props.modelValue.map((row) => api.repeaterDragKey(row)));
|
||||
assert.strictEqual(keys.size, 2);
|
||||
const added = { points: 0 };
|
||||
assert.ok(!keys.has(api.repeaterDragKey(added)), '新行必须拿到全新 key');
|
||||
assert.ok(!keys.has(api.repeaterDragKey({ points: 0 })), '新行必须拿到全新 key');
|
||||
});
|
||||
Reference in new issue
Block a user