diff --git a/assets/admin/src/components/form/AksField.vue b/assets/admin/src/components/form/AksField.vue index 3ebf7e63..29602271 100644 --- a/assets/admin/src/components/form/AksField.vue +++ b/assets/admin/src/components/form/AksField.vue @@ -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) } diff --git a/tests/widget-system/repeater-row-key-stability.test.cjs b/tests/widget-system/repeater-row-key-stability.test.cjs index 443a77c4..026c04d4 100644 --- a/tests/widget-system/repeater-row-key-stability.test.cjs +++ b/tests/widget-system/repeater-row-key-stability.test.cjs @@ -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'); });